Enable user mentions in Rich Text Editor

This commit is contained in:
aringenbach
2023-03-08 15:16:19 +01:00
parent 5b48698587
commit 935e61e1bb
16 changed files with 267 additions and 39 deletions

View File

@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift",
"state" : {
"revision" : "addf90f3e2a6ab46bd2b2febe117d9cddb646e7d",
"version" : "1.1.1"
"revision" : "efa0b75e383a8f8a8269b871cbdee3d9a3a99060",
"version" : "1.2.0"
}
},
{

View File

@@ -25,13 +25,18 @@ import UIKit
avatarLeading: 2.0,
avatarSideLength: 16.0,
itemSpacing: 4)
private weak var messageTextView: MXKMessageTextView?
private weak var pillViewFlusher: PillViewFlusher?
// MARK: - Override
override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) {
super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location)
self.messageTextView = parentView?.superview as? MXKMessageTextView
// Try to register a flusher for the pills.
if let pillViewFlusher = parentView?.superview as? PillViewFlusher {
self.pillViewFlusher = pillViewFlusher
} else {
MXLog.debug("[PillAttachmentViewProvider]: no handler found, pills will not be flushed properly")
}
}
override func loadView() {
@@ -55,6 +60,6 @@ import UIKit
mediaManager: mainSession?.mediaManager,
andPillData: pillData)
view = pillView
messageTextView?.registerPillView(pillView)
pillViewFlusher?.registerPillView(pillView)
}
}

View File

@@ -0,0 +1,39 @@
//
// 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 UIKit
import WysiwygComposer
/// Defines behaviour for an object that is able to manage views created
/// by a `NSTextAttachmentViewProvider`. This can be implemented
/// by an `UITextView` that would keep track of views in order to
/// (internally) clear them when required (e.g. when setting a new attributed text).
///
/// Note: It is necessary to clear views manually due to a bug in iOS. See `MXKMessageTextView`.
@available(iOS 15.0, *)
protocol PillViewFlusher: AnyObject {
/// Register a pill view that has been added through `NSTextAttachmentViewProvider`.
/// Should be called within the `loadView` function in order to clear the pills properly on text updates.
///
/// - Parameter pillView: View to register.
func registerPillView(_ pillView: UIView)
}
@available(iOS 15.0, *)
extension MXKMessageTextView: PillViewFlusher { }
@available(iOS 15.0, *)
extension WysiwygTextView: PillViewFlusher { }

View File

@@ -74,6 +74,48 @@ class PillsFormatter: NSObject {
return newAttr
}
/// Insert text attachments for pills inside given attributed string containing markdown.
///
/// - Parameters:
/// - markdownString: An attributed string with markdown formatting
/// - roomState: The current room state
/// - Returns: A new attributed string with pills.
static func insertPills(in markdownString: NSAttributedString, roomState: MXRoomState) -> NSAttributedString {
// Create a regexp that detects markdown links.
let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)"
guard let regExp = try? NSRegularExpression(pattern: pattern) else { return markdownString }
let matches = regExp.matches(in: markdownString.string,
range: .init(location: 0, length: markdownString.length))
// If we have some matches, replace permalinks by a pill version.
let mutable = NSMutableAttributedString(attributedString: markdownString)
for match in matches.reversed() {
// Range at 2 is the URL, no need to care about the other parts because
// we are retrieving the most recent display name from the room state.
let urlRange = match.range(at: 2)
var url = markdownString.attributedSubstring(from: urlRange).string
// Note: a valid markdown link can be written with
// enclosing <..>, remove them for userId detection.
if url.first == "<" && url.last == ">" {
url = String(url[url.index(after: url.startIndex)...url.index(url.endIndex, offsetBy: -2)])
}
// If we find a user matching the link, replace the
// entire range of the match with a mention pill.
if let userId = userIdFromPermalink(url),
let roomMember = roomMember(withUserId: userId,
roomState: roomState,
andLatestRoomState: nil) {
let attachmentString = mentionPill(withRoomMember: roomMember, isHighlighted: false, font: UIFont.systemFont(ofSize: 14))
mutable.replaceCharacters(in: match.range, with: attachmentString)
}
}
return mutable
}
/// Creates a string with all pills of given attributed string replaced by display names.
///
/// - Parameters:
@@ -160,7 +202,6 @@ class PillsFormatter: NSObject {
}
}
}
}
// MARK: - Private Methods
@@ -175,4 +216,30 @@ extension PillsFormatter {
}
return string
}
/// Extract user id from given permalink
/// - Parameter permalink: the permalink
/// - Returns: userId, if any
static func userIdFromPermalink(_ permalink: String) -> String? {
let baseUrl: String
if let clientBaseUrl = BuildSettings.clientPermalinkBaseUrl {
baseUrl = String(format: "%@/#/user/", clientBaseUrl)
} else {
baseUrl = String(format: "%@/#/", kMXMatrixDotToUrl)
}
return permalink.starts(with: baseUrl) ? String(permalink.dropFirst(baseUrl.count)) : nil
}
/// Retrieve the latest available `MXRoomMember` from given data.
///
/// - Parameters:
/// - userId: the id of the user
/// - roomState: room state for message
/// - latestRoomState: latest room state of the room containing this message
/// - Returns: the room member, if available
static func roomMember(withUserId userId: String,
roomState: MXRoomState,
andLatestRoomState latestRoomState: MXRoomState?) -> MXRoomMember? {
return latestRoomState?.members.member(withUserId: userId) ?? roomState.members.member(withUserId: userId)
}
}

View File

@@ -5149,6 +5149,11 @@ static CGSize kThreadListBarButtonItemImageSize;
[self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage];
}
- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern
{
[self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern];
}
- (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView
{
// Consider opening the action menu as beginning to type and share encryption keys if requested.

View File

@@ -20,40 +20,42 @@ import WysiwygComposer
extension RoomViewController {
// MARK: - Override
open override func mention(_ roomMember: MXRoomMember) {
guard let inputToolbar = inputToolbar else {
return
}
let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage)
if inputToolbar.attributedTextMessage.length > 0 {
if #available(iOS 15.0, *) {
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
isHighlighted: false,
font: inputToolbar.textDefaultFont))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(" ")
} else if roomMember.userId == self.mainSession.myUser.userId {
newAttributedString.appendString("/me ")
if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled {
wysiwygInputToolbar.mention(roomMember)
wysiwygInputToolbar.becomeFirstResponder()
} else {
if #available(iOS 15.0, *) {
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
isHighlighted: false,
font: inputToolbar.textDefaultFont))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(": ")
}
guard let attributedText = inputToolbarView.attributedTextMessage else { return }
let newAttributedString = NSMutableAttributedString(attributedString: attributedText)
inputToolbar.attributedTextMessage = newAttributedString
inputToolbar.becomeFirstResponder()
if attributedText.length > 0 {
if #available(iOS 15.0, *) {
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
isHighlighted: false,
font: UIFont.systemFont(ofSize: 14)))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(" ")
} else if roomMember.userId == self.mainSession.myUser.userId {
newAttributedString.appendString("/me ")
} else {
if #available(iOS 15.0, *) {
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
isHighlighted: false,
font: UIFont.systemFont(ofSize: 14)))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(": ")
}
inputToolbarView.attributedTextMessage = newAttributedString
inputToolbarView.becomeFirstResponder()
}
}
/// Send the formatted text message and its raw counterpat to the room
/// Send the formatted text message and its raw counterpart to the room
///
/// - Parameter rawTextMsg: the raw text message
/// - Parameter htmlMsg: the html text message
@@ -153,7 +155,22 @@ extension RoomViewController {
@objc func togglePlainTextMode() {
RiotSettings.shared.enableWysiwygTextFormatting.toggle()
wysiwygInputToolbar?.textFormattingEnabled.toggle()
guard let wysiwygInputToolbar else { return }
// Switching from plain -> RTE, replace Pills by valid markdown links for parsing.
if !wysiwygInputToolbar.textFormattingEnabled, #available(iOS 15.0, *),
let attributedText = wysiwygInputToolbar.attributedTextMessage {
wysiwygInputToolbar.attributedTextMessage = NSAttributedString(string: PillsFormatter.stringByReplacingPills(in: attributedText, mode: .markdown))
}
wysiwygInputToolbar.textFormattingEnabled.toggle()
// Switching from RTE -> plain, replace markdown links with Pills.
if !wysiwygInputToolbar.textFormattingEnabled, #available(iOS 15.0, *),
let attributedText = wysiwygInputToolbar.attributedTextMessage {
wysiwygInputToolbar.attributedTextMessage = PillsFormatter.insertPills(in: attributedText, roomState: self.roomDataSource.roomState)
}
}
@objc func didChangeMaximisedState(_ isMaximised: Bool) {
@@ -251,6 +268,21 @@ extension RoomViewController {
composerLinkActionBridgePresenter = presenter
presenter.present(from: self, animated: true)
}
@objc func didRequestAttachmentStringForLink(_ link: String, andDisplayName: String) -> NSAttributedString? {
guard #available(iOS 15.0, *),
let userId = PillsFormatter.userIdFromPermalink(link),
let roomState = self.roomDataSource.roomState,
let member = PillsFormatter.roomMember(withUserId: userId,
roomState: roomState,
andLatestRoomState: nil) else {
return nil
}
return PillsFormatter.mentionPill(withRoomMember: member,
isHighlighted: false,
font: UIFont.systemFont(ofSize: 14))
}
@objc func showWaitingOtherParticipantHeader() {
let controller = VectorHostingController(rootView: RoomWaitingForMembers())

View File

@@ -21,6 +21,7 @@
@class RoomActionsBar;
@class RoomInputToolbarView;
@class LinkActionWrapper;
@class SuggestionPatternWrapper;
/**
Destination of the message in the composer
@@ -80,6 +81,10 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
- (void)didSendLinkAction: (LinkActionWrapper *)linkAction;
- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern;
- (nullable NSAttributedString *)didRequestAttachmentStringForLink: (NSString *)link andDisplayName: (NSString *)displayName;
@end
/**

View File

@@ -43,8 +43,9 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
private var heightConstraint: NSLayoutConstraint!
private var voiceMessageBottomConstraint: NSLayoutConstraint?
private var hostingViewController: VectorHostingController!
private var wysiwygViewModel = WysiwygComposerViewModel(
parserStyle: WysiwygInputToolbarView.parserStyle
private lazy var wysiwygViewModel = WysiwygComposerViewModel(
parserStyle: WysiwygInputToolbarView.parserStyle,
permalinkReplacer: self
)
/// Compute current HTML parser style for composer.
private static var parserStyle: HTMLParserStyle {
@@ -85,6 +86,19 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
override var isFocused: Bool {
viewModel.isFocused
}
override var attributedTextMessage: NSAttributedString? {
// Note: this is only interactive in plain text mode. If RTE is enabled,
// APIs from the composer view model should be used.
get {
guard !self.textFormattingEnabled else { return nil }
return self.wysiwygViewModel.textView.attributedText
}
set {
guard !self.textFormattingEnabled else { return }
self.wysiwygViewModel.textView.attributedText = newValue
}
}
var isMaximised: Bool {
wysiwygViewModel.maximised
@@ -217,6 +231,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
override func dismissKeyboard() {
self.viewModel.dismissKeyboard()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
self.wysiwygViewModel.textView.becomeFirstResponder()
}
override func dismissValidationView(_ validationView: MXKImageView!) {
super.dismissValidationView(validationView)
@@ -239,6 +258,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
wysiwygViewModel.applyLinkOperation(linkOperation)
}
func mention(_ member: MXRoomMember) {
self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId),
name: member.displayname,
key: .at)
}
// MARK: - Private
@@ -291,6 +316,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
setVoiceMessageToolbarIsHidden(!isEmpty)
case let .linkTapped(linkAction):
toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction))
case let .suggestion(pattern):
toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern))
}
}
@@ -412,6 +439,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
}
extension WysiwygInputToolbarView: PermalinkReplacer {
func replacementForLink(_ link: String, text: String) -> NSAttributedString? {
return toolbarViewDelegate?.didRequestAttachmentString(forLink: link, andDisplayName: text)
}
}
// MARK: - LegacySendModeAdapter
fileprivate extension ComposerSendMode {

View File

@@ -229,12 +229,14 @@ enum ComposerViewAction: Equatable {
case contentDidChange(isEmpty: Bool)
case linkTapped(linkAction: LinkAction)
case storeSelection(selection: NSRange)
case suggestion(pattern: SuggestionPattern?)
}
enum ComposerViewModelResult: Equatable {
case cancel
case contentDidChange(isEmpty: Bool)
case linkTapped(LinkAction: LinkAction)
case suggestion(pattern: SuggestionPattern?)
}
final class LinkActionWrapper: NSObject {
@@ -245,3 +247,12 @@ final class LinkActionWrapper: NSObject {
super.init()
}
}
final class SuggestionPatternWrapper: NSObject {
let suggestionPattern: SuggestionPattern?
init(_ suggestionPattern: SuggestionPattern?) {
self.suggestionPattern = suggestionPattern
super.init()
}
}

View File

@@ -248,6 +248,9 @@ struct Composer: View {
wysiwygViewModel.maximised = false
}
}
.onChange(of: wysiwygViewModel.suggestionPattern) { newValue in
sendMentionPattern(pattern: newValue)
}
}
private func storeCurrentSelection() {
@@ -258,6 +261,10 @@ struct Composer: View {
let linkAction = wysiwygViewModel.getLinkAction()
viewModel.send(viewAction: .linkTapped(linkAction: linkAction))
}
private func sendMentionPattern(pattern: SuggestionPattern?) {
viewModel.send(viewAction: .suggestion(pattern: pattern))
}
}
private extension WysiwygComposerViewModel {

View File

@@ -90,6 +90,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol
callback?(.linkTapped(LinkAction: linkAction))
case let .storeSelection(selection):
selectionToRestore = selection
case let .suggestion(pattern: pattern):
callback?(.suggestion(pattern: pattern))
}
}

View File

@@ -18,6 +18,7 @@ import Combine
import Foundation
import SwiftUI
import UIKit
import WysiwygComposer
protocol UserSuggestionCoordinatorDelegate: AnyObject {
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?)
@@ -92,6 +93,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionService.processTextMessage(textMessage)
}
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) {
userSuggestionService.processSuggestionPattern(suggestionPattern)
}
// MARK: - Public
func start() { }

View File

@@ -44,6 +44,10 @@ final class UserSuggestionCoordinatorBridge: NSObject {
func processTextMessage(_ textMessage: String) {
userSuggestionCoordinator.processTextMessage(textMessage)
}
func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) {
userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern)
}
func toPresentable() -> UIViewController? {
userSuggestionCoordinator.toPresentable()

View File

@@ -16,6 +16,7 @@
import Combine
import Foundation
import WysiwygComposer
struct RoomMembersProviderMember {
var userId: String
@@ -85,6 +86,16 @@ class UserSuggestionService: UserSuggestionServiceProtocol {
currentTextTriggerSubject.send(lastComponent)
}
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) {
guard let suggestionPattern, suggestionPattern.key == .at else {
items.send([])
currentTextTriggerSubject.send(nil)
return
}
currentTextTriggerSubject.send("@" + suggestionPattern.text)
}
// MARK: - Private

View File

@@ -16,6 +16,7 @@
import Combine
import Foundation
import WysiwygComposer
protocol UserSuggestionItemProtocol: Avatarable {
var userId: String { get }
@@ -29,6 +30,7 @@ protocol UserSuggestionServiceProtocol {
var currentTextTrigger: String? { get }
func processTextMessage(_ textMessage: String?)
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?)
}
// MARK: Avatarable

View File

@@ -56,7 +56,7 @@ packages:
branch: 0.0.1
WysiwygComposer:
url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift
version: 1.1.1
version: 1.2.0
DeviceKit:
url: https://github.com/devicekit/DeviceKit
majorVersion: 4.7.0