Merge pull request #7416 from vector-im/aringenbach/enable_rte_user_mentions

Enable user mentions in Rich Text Editor
This commit is contained in:
aringenbach 2023-04-14 16:19:56 +02:00 committed by GitHub
commit c1a9f31ded
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 679 additions and 175 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" : "758f226a92d6726ab626c1e78ecd183bdba77016",
"version" : "2.0.0"
}
},
{

View file

@ -382,6 +382,11 @@ typedef enum : NSUInteger
*/
@property (nonatomic) NSAttributedString *attributedTextMessage;
/**
Default font for the message composer.
*/
@property (nonatomic, readonly, nonnull) UIFont *defaultFont;
- (void)dismissValidationView:(MXKImageView*)validationView;
@end

View file

@ -358,6 +358,10 @@
self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text];
}
- (UIFont *)defaultFont
{
return [UIFont systemFontOfSize:15.f];
}
#pragma mark - MXKFileSizes

View file

@ -25,25 +25,29 @@ import UIKit
avatarLeading: 2.0,
avatarSideLength: 16.0,
itemSpacing: 4)
private weak var messageTextView: MXKMessageTextView?
private weak var messageTextView: UITextView?
private var pillViewFlusher: PillViewFlusher? {
messageTextView as? 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
// Keep a reference to the parent text view for size adjustments and pills flushing.
messageTextView = parentView?.superview as? UITextView
}
override func loadView() {
super.loadView()
guard let textAttachment = self.textAttachment as? PillTextAttachment else {
MXLog.debug("[PillAttachmentViewProvider]: attachment is missing or not of expected class")
MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class")
return
}
guard var pillData = textAttachment.data else {
MXLog.debug("[PillAttachmentViewProvider]: attachment misses pill data")
MXLog.failure("[PillAttachmentViewProvider]: attachment misses pill data")
return
}
@ -59,6 +63,11 @@ import UIKit
mediaManager: mainSession?.mediaManager,
andPillData: pillData)
view = pillView
messageTextView?.registerPillView(pillView)
if let pillViewFlusher {
pillViewFlusher.registerPillView(pillView)
} else {
MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly")
}
}
}

View file

@ -26,14 +26,14 @@ private enum PillAttachmentKind {
struct PillProvider {
private let session: MXSession
private let eventFormatter: MXKEventFormatter
private let event: MXEvent
private let event: MXEvent?
private let roomState: MXRoomState
private let latestRoomState: MXRoomState?
private let isEditMode: Bool
init(withSession session: MXSession,
eventFormatter: MXKEventFormatter,
event: MXEvent,
event: MXEvent?,
roomState: MXRoomState,
andLatestRoomState latestRoomState: MXRoomState?,
isEditMode: Bool) {
@ -46,7 +46,7 @@ struct PillProvider {
self.isEditMode = isEditMode
}
func pillTextAttachmentString(forUrl url: URL, withLabel label: String, event: MXEvent) -> NSAttributedString? {
func pillTextAttachmentString(forUrl url: URL, withLabel label: String) -> NSAttributedString? {
// Try to get a pill from this url
guard let pillType = PillType.from(url: url) else {
@ -133,6 +133,10 @@ struct PillProvider {
let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl
let displayName = roomMember?.displayname ?? user?.displayName ?? userId
let isHighlighted = userId == session.myUserId
// No actual event means it is a composer Pill. No highlight
&& event != nil
// No highlight on self-mentions
&& event?.sender != session.myUserId
let avatar: PillTextAttachmentItem
if roomMember == nil && user == nil {

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

@ -65,7 +65,7 @@ class PillsFormatter: NSObject {
// try to get a mention pill from the url
let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) }
if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "", event: event) {
if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "") {
// replace the url with the pill
newAttr.replaceCharacters(in: range, with: attachmentString)
}
@ -74,6 +74,41 @@ 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
/// - font: The font to use for the pill text
/// - Returns: A new attributed string with pills.
static func insertPills(in markdownString: NSAttributedString,
withSession session: MXSession,
eventFormatter: MXKEventFormatter,
roomState: MXRoomState,
font: UIFont) -> NSAttributedString {
let matches = markdownLinks(in: markdownString)
// If we have some matches, replace permalinks by a pill version.
guard !matches.isEmpty else { return markdownString }
let pillProvider = PillProvider(withSession: session,
eventFormatter: eventFormatter,
event: nil,
roomState: roomState,
andLatestRoomState: nil,
isEditMode: true)
let mutable = NSMutableAttributedString(attributedString: markdownString)
matches.reversed().forEach {
if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) {
mutable.replaceCharacters(in: $0.range, with: attachmentString)
}
}
return mutable
}
/// Creates a string with all pills of given attributed string replaced by display names.
///
/// - Parameters:
@ -123,6 +158,20 @@ class PillsFormatter: NSObject {
}
return attributedStringWithAttachment(attachment, link: url, font: font)
}
static func mentionPill(withUrl url: URL,
andLabel label: String,
session: MXSession,
eventFormatter: MXKEventFormatter,
roomState: MXRoomState) -> NSAttributedString? {
let pillProvider = PillProvider(withSession: session,
eventFormatter: eventFormatter,
event: nil,
roomState: roomState,
andLatestRoomState: nil,
isEditMode: true)
return pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label)
}
/// Update alpha of all `PillTextAttachment` contained in given attributed string.
///
@ -160,12 +209,45 @@ class PillsFormatter: NSObject {
}
}
}
}
// MARK: - Private Methods
@available (iOS 15.0, *)
extension PillsFormatter {
struct MarkdownLinkResult: Equatable {
let url: URL
let label: String
let range: NSRange
}
static func markdownLinks(in attributedString: NSAttributedString) -> [MarkdownLinkResult] {
// Create a regexp that detects markdown links.
// Pattern source: https://gist.github.com/hugocf/66d6cd241eff921e0e02
let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)"
guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] }
let matches = regExp.matches(in: attributedString.string,
range: .init(location: 0, length: attributedString.length))
return matches.compactMap { match in
let labelRange = match.range(at: 1)
let urlRange = match.range(at: 2)
let label = attributedString.attributedSubstring(from: labelRange).string
var url = attributedString.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 let url = URL(string: url) {
return MarkdownLinkResult(url: url, label: label, range: match.range)
} else {
return nil
}
}
}
static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString {
let string = NSMutableAttributedString(attachment: attachment)

View file

@ -5150,6 +5150,21 @@ static CGSize kThreadListBarButtonItemImageSize;
[self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage];
}
- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern
{
[self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern];
}
- (UserSuggestionViewModelContextWrapper *)userSuggestionContext
{
return [self.userSuggestionCoordinator sharedContext];
}
- (MXMediaManager *)mediaManager
{
return self.roomDataSource.mxSession.mediaManager;
}
- (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView
{
// Consider opening the action menu as beginning to type and share encryption keys if requested.

View file

@ -14,46 +14,52 @@
// limitations under the License.
//
import HTMLParser
import UIKit
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: inputToolbarView.defaultFont))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(" ")
} else if roomMember.userId == self.mainSession.myUser.userId {
newAttributedString.appendString("/me ")
newAttributedString.addAttribute(.font,
value: inputToolbarView.defaultFont,
range: .init(location: 0, length: newAttributedString.length))
} else {
if #available(iOS 15.0, *) {
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
isHighlighted: false,
font: inputToolbarView.defaultFont))
} 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
@ -361,6 +367,48 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate {
}
}
// MARK: - PermalinkReplacer
extension RoomViewController: PermalinkReplacer {
public func replacementForLink(_ url: String, text: String) -> NSAttributedString? {
guard #available(iOS 15.0, *),
let url = URL(string: url),
let session = roomDataSource.mxSession,
let eventFormatter = roomDataSource.eventFormatter,
let roomState = roomDataSource.roomState else {
return nil
}
return PillsFormatter.mentionPill(withUrl: url,
andLabel: text,
session: session,
eventFormatter: eventFormatter,
roomState: roomState)
}
public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString {
guard #available(iOS 15.0, *),
let roomDataSource,
let session = roomDataSource.mxSession,
let eventFormatter = roomDataSource.eventFormatter,
let roomState = roomDataSource.roomState else {
return attributedString
}
return PillsFormatter.insertPills(in: attributedString,
withSession: session,
eventFormatter: eventFormatter,
roomState: roomState,
font: inputToolbarView.defaultFont)
}
public func restoreMarkdown(in attributedString: NSAttributedString) -> String {
if #available(iOS 15.0, *) {
return PillsFormatter.stringByReplacingPills(in: attributedString, mode: .markdown)
} else {
return attributedString.string
}
}
}
// MARK: - VoiceBroadcast
extension RoomViewController {
@objc func stopUncompletedVoiceBroadcastIfNeeded() {

View file

@ -39,6 +39,7 @@
<outletCollection property="toolbarContainerConstraints" destination="pRw-S0-6WL" id="q4S-0g-sqQ"/>
<outletCollection property="toolbarContainerConstraints" destination="QO8-nF-xys" id="aQe-20-4Pq"/>
<outletCollection property="toolbarContainerConstraints" destination="acJ-g8-R7x" id="uEo-Ez-seV"/>
<outletCollection property="toolbarContainerConstraints" destination="ave-fu-X1D" id="xfF-6Q-MDo"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>

View file

@ -21,6 +21,8 @@
@class RoomActionsBar;
@class RoomInputToolbarView;
@class LinkActionWrapper;
@class SuggestionPatternWrapper;
@class UserSuggestionViewModelContextWrapper;
/**
Destination of the message in the composer
@ -59,7 +61,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
@param toolbarView the room input toolbar view
*/
- (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView*)toolbarView;
- (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView*)toolbarView;
/**
Inform the delegate that the action menu was opened.
@ -80,6 +82,12 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
- (void)didSendLinkAction: (LinkActionWrapper *)linkAction;
- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern;
- (UserSuggestionViewModelContextWrapper *)userSuggestionContext;
- (MXMediaManager *)mediaManager;
@end
/**
@ -128,8 +136,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
*/
@property (nonatomic, weak, readonly) UIButton *attachMediaButton;
@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont;
/**
Adds a voice message toolbar view to be displayed inside this input toolbar
*/

View file

@ -154,7 +154,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
{
NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage];
[mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor,
NSFontAttributeName: self.textDefaultFont }
NSFontAttributeName: self.defaultFont }
range:NSMakeRange(0, mutableTextMessage.length)];
attributedTextMessage = mutableTextMessage;
}
@ -181,7 +181,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
return self.textView.text;
}
- (UIFont *)textDefaultFont
- (UIFont *)defaultFont
{
if (self.textView.font)
{

View file

@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
// MARK: Public
override var delegate: MXKRoomInputToolbarViewDelegate! {
didSet {
setupComposerIfNeeded()
}
}
override var placeholder: String! {
get {
@ -85,6 +91,23 @@ 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
}
}
override var defaultFont: UIFont {
return UIFont.preferredFont(forTextStyle: .body)
}
var isMaximised: Bool {
wysiwygViewModel.maximised
@ -120,93 +143,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
return (delegate as? RoomInputToolbarViewDelegate) ?? nil
}
private var permalinkReplacer: PermalinkReplacer? {
return (delegate as? PermalinkReplacer)
}
override func awakeFromNib() {
super.awakeFromNib()
viewModel = ComposerViewModel(
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
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,
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
sendMessageAction: { [weak self] content in
guard let self = self else { return }
self.sendWysiwygMessage(content: content)
}, showSendMediaActions: { [weak self] in
guard let self = self else { return }
self.showSendMediaActions()
}).introspectTextView { [weak self] textView in
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)
NSLayoutConstraint.activate([
heightConstraint,
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
cancellables = [
hostingViewController.heightPublisher
.removeDuplicates()
.sink(receiveValue: { [weak self] idealHeight in
guard let self = self else { return }
self.updateToolbarHeight(wysiwygHeight: idealHeight)
}),
// Required to update the view constraints after minimise/maximise is tapped
wysiwygViewModel.$idealHeight
.removeDuplicates()
.sink { [weak hostingViewController] _ in
hostingViewController?.view.setNeedsLayout()
},
wysiwygViewModel.$maximised
.dropFirst()
.removeDuplicates()
.sink { [weak self] value in
guard let self = self else { return }
self.toolbarViewDelegate?.didChangeMaximisedState(value)
self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0
if !value {
self.voiceMessageBottomConstraint?.constant = 2
}
}
]
update(theme: ThemeService.shared().theme)
registerThemeServiceDidChangeThemeNotification()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
setupComposerIfNeeded()
}
override func customizeRendering() {
@ -217,6 +162,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,8 +189,119 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
}
wysiwygViewModel.applyLinkOperation(linkOperation)
}
func mention(_ member: MXRoomMember) {
self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId),
name: member.displayname,
mentionType: .user)
}
// MARK: - Private
private func setupComposerIfNeeded() {
guard hostingViewController == nil,
let toolbarViewDelegate,
let permalinkReplacer else { return }
viewModel = ComposerViewModel(
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
isLandscapePhone: isLandscapePhone,
bindings: ComposerBindings(focused: false)))
viewModel.callback = { [weak self] result in
self?.handleViewModelResult(result)
}
wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting
wysiwygViewModel.permalinkReplacer = permalinkReplacer
inputAccessoryViewForKeyboard = UIView(frame: .zero)
let composer = Composer(
viewModel: viewModel.context,
wysiwygViewModel: wysiwygViewModel,
userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context,
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
sendMessageAction: { [weak self] content in
guard let self = self else { return }
self.sendWysiwygMessage(content: content)
}, showSendMediaActions: { [weak self] in
guard let self = self else { return }
self.showSendMediaActions()
})
.introspectTextView { [weak self] textView in
guard let self = self else { return }
textView.inputAccessoryView = self.inputAccessoryViewForKeyboard
}
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: toolbarViewDelegate.mediaManager())))
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)
NSLayoutConstraint.activate([
heightConstraint,
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
cancellables = [
hostingViewController.heightPublisher
.removeDuplicates()
.sink(receiveValue: { [weak self] idealHeight in
guard let self = self else { return }
self.updateToolbarHeight(wysiwygHeight: idealHeight)
}),
// Required to update the view constraints after minimise/maximise is tapped
wysiwygViewModel.$idealHeight
.removeDuplicates()
.sink { [weak hostingViewController] _ in
hostingViewController?.view.setNeedsLayout()
},
wysiwygViewModel.$maximised
.dropFirst()
.removeDuplicates()
.sink { [weak self] value in
guard let self = self else { return }
self.toolbarViewDelegate?.didChangeMaximisedState(value)
self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0
if !value {
self.voiceMessageBottomConstraint?.constant = 2
}
},
wysiwygViewModel.$plainTextContent
.dropFirst()
.removeDuplicates()
.sink { [weak self] value in
guard let self else { return }
self.textMessage = value.string
self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self)
}
]
update(theme: ThemeService.shared().theme)
registerThemeServiceDidChangeThemeNotification()
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil
)
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
}
@objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
@ -291,6 +352,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
setVoiceMessageToolbarIsHidden(!isEmpty)
case let .linkTapped(linkAction):
toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction))
case let .suggestion(pattern):
toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern))
}
}

View file

@ -41,6 +41,7 @@ extension ComposerLinkActionViewState {
switch linkAction {
case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle
case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle
case .disabled: return ""
}
}
@ -64,6 +65,7 @@ extension ComposerLinkActionViewState {
case .createWithText: return bindings.text.isEmpty
case .create: return false
case .edit: return !bindings.hasEditedUrl
case .disabled: return false
}
}
}

View file

@ -46,6 +46,9 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos
initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings)
case .create:
initialViewState = .init(linkAction: .create, bindings: simpleBindings)
case .disabled:
// Note: Unreachable
initialViewState = .init(linkAction: .disabled, bindings: simpleBindings)
}
super.init(initialViewState: initialViewState)
@ -74,6 +77,8 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos
.setLink(urlString: state.bindings.linkUrl)
)
)
case .disabled:
break
}
}
}

View file

@ -29,12 +29,22 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) {
let viewModel: ComposerViewModel
let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: []))
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)
@ -57,6 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
Spacer()
Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygviewModel,
userSuggestionSharedContext: userSuggestionViewModel.context,
resizeAnimationDuration: 0.1,
sendMessageAction: { _ in },
showSendMediaActions: { })
@ -70,3 +81,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
)
}
}
private final class MockUserSuggestionViewModel: UserSuggestionViewModelType {
}

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,21 @@ final class LinkActionWrapper: NSObject {
super.init()
}
}
final class SuggestionPatternWrapper: NSObject {
let suggestionPattern: SuggestionPattern?
init(_ suggestionPattern: SuggestionPattern?) {
self.suggestionPattern = suggestionPattern
super.init()
}
}
final class UserSuggestionViewModelWrapper: NSObject {
let userSuggestionViewModel: UserSuggestionViewModel
init(_ userSuggestionViewModel: UserSuggestionViewModel) {
self.userSuggestionViewModel = userSuggestionViewModel
super.init()
}
}

View file

@ -23,6 +23,7 @@ struct Composer: View {
// MARK: Private
@ObservedObject private var viewModel: ComposerViewModelType.Context
@ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel
private let userSuggestionSharedContext: UserSuggestionViewModelType.Context
private let resizeAnimationDuration: Double
private let sendMessageAction: (WysiwygComposerContent) -> Void
@ -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 {
@ -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 {
@ -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) {
@ -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()
@ -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
@ -195,11 +223,13 @@ struct Composer: View {
init(
viewModel: ComposerViewModelType.Context,
wysiwygViewModel: WysiwygComposerViewModel,
userSuggestionSharedContext: UserSuggestionViewModelType.Context,
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
@ -213,17 +243,23 @@ 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, showBackgroundShadow: false)
}
}
.frame(height: composerHeight)
if viewModel.viewState.textFormattingEnabled {
HStack(alignment: .center, spacing: 0) {
sendMediaButton
@ -248,6 +284,9 @@ struct Composer: View {
wysiwygViewModel.maximised = false
}
}
.onChange(of: wysiwygViewModel.suggestionPattern) { newValue in
sendMentionPattern(pattern: newValue)
}
}
private func storeCurrentSelection() {
@ -258,6 +297,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?)
@ -31,6 +32,15 @@ struct UserSuggestionCoordinatorParameters {
let userID: String
}
/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c.
final class UserSuggestionViewModelContextWrapper: NSObject {
let context: UserSuggestionViewModelType.Context
init(context: UserSuggestionViewModelType.Context) {
self.context = context
}
}
final class UserSuggestionCoordinator: Coordinator, Presentable {
// MARK: - Properties
@ -99,6 +109,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionService.processTextMessage(textMessage)
}
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) {
userSuggestionService.processSuggestionPattern(suggestionPattern)
}
// MARK: - Public
func start() { }
@ -107,6 +121,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionHostingController
}
func sharedContext() -> UserSuggestionViewModelContextWrapper {
UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext)
}
// MARK: - Private
private func calculateViewHeight() -> CGFloat {

View file

@ -45,10 +45,18 @@ final class UserSuggestionCoordinatorBridge: NSObject {
func processTextMessage(_ textMessage: String) {
userSuggestionCoordinator.processTextMessage(textMessage)
}
func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) {
userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern)
}
func toPresentable() -> UIViewController? {
userSuggestionCoordinator.toPresentable()
}
func sharedContext() -> UserSuggestionViewModelContextWrapper {
userSuggestionCoordinator.sharedContext()
}
}
extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {

View file

@ -16,6 +16,7 @@
import Combine
import Foundation
import WysiwygComposer
struct RoomMembersProviderMember {
var userId: String
@ -91,6 +92,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

@ -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

View file

@ -17,5 +17,9 @@
import Foundation
protocol UserSuggestionViewModelProtocol {
/// 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 the data.
var sharedContext: UserSuggestionViewModelType.Context { get }
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
}

View file

@ -35,6 +35,7 @@ struct UserSuggestionList: View {
// MARK: Public
@ObservedObject var viewModel: UserSuggestionViewModel.Context
var showBackgroundShadow: Bool = true
var body: some View {
if viewModel.viewState.items.isEmpty {
@ -46,25 +47,12 @@ struct UserSuggestionList: View {
userId: "Prototype")
.background(ViewFrameReader(frame: $prototypeListItemFrame))
.hidden()
BackgroundView {
List(viewModel.viewState.items) { item in
Button {
viewModel.send(viewAction: .selectedItem(item))
} label: {
UserSuggestionListItem(
avatar: item.avatar,
displayName: item.displayName,
userId: item.id
)
.padding(.bottom, Constants.listItemPadding)
.padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding)
}
if showBackgroundShadow {
BackgroundView {
list()
}
.listStyle(PlainListStyle())
.frame(height: min(Constants.maxHeight,
min(contentHeightForRowCount(Constants.maxVisibleRows),
contentHeightForRowCount(viewModel.viewState.items.count))))
.id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues.
} else {
list()
}
}
}
@ -73,6 +61,27 @@ struct UserSuggestionList: View {
private func contentHeightForRowCount(_ count: Int) -> CGFloat {
(prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding
}
private func list() -> some View {
List(viewModel.viewState.items) { item in
Button {
viewModel.send(viewAction: .selectedItem(item))
} label: {
UserSuggestionListItem(
avatar: item.avatar,
displayName: item.displayName,
userId: item.id
)
.padding(.bottom, Constants.listItemPadding)
.padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding)
}
}
.listStyle(PlainListStyle())
.frame(height: min(Constants.maxHeight,
min(contentHeightForRowCount(Constants.maxVisibleRows),
contentHeightForRowCount(viewModel.viewState.items.count))))
.id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues.
}
}
private struct BackgroundView<Content: View>: View {

View file

@ -29,12 +29,14 @@ private enum Inputs {
static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org")
static let alicePermalink = "https://matrix.to/#/@alice:matrix.org"
static let mentionToAlice = NSAttributedString(string: aliceDisplayname, attributes: [.link: URL(string: alicePermalink)!])
static let markdownLinkToAlice = "[Alice](\(alicePermalink))"
static let markdownLinkToAlice = "[\(aliceDisplayname)](\(alicePermalink))"
static let bobUserId = "@bob:matrix.org"
static let bobDisplayname = "Bob"
static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ"
static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId)
static let bobPermalink = "https://matrix.to/#/@bob:matrix.org"
static let markdownLinkToBob = "[\(bobDisplayname)](\(bobPermalink))"
static let anotherUserId = "@another.user:matrix.org"
static let anotherUserPermalink = "https://matrix.to/#/@another.user:matrix.org"
@ -310,7 +312,7 @@ class PillsFormatterTests: XCTestCase {
case .room(let userId):
XCTAssertEqual(userId, Inputs.roomId)
switch pillTextAttachmentData.items.first {
case .asset(let assetName, let parameters):
case .asset(let assetName, _):
XCTAssertEqual(assetName, "link_icon")
default:
XCTFail("First pill item should be the asset")
@ -436,7 +438,7 @@ class PillsFormatterTests: XCTestCase {
XCTAssertEqual(roomId, Inputs.anotherRoomId)
XCTAssertEqual(messageId, Inputs.messageEventId)
switch pillTextAttachmentData.items.first {
case .asset(let name, let parameters):
case .asset(let name, _):
XCTAssertEqual(name, "link_icon")
default:
XCTFail("First pill item should be the asset")
@ -445,6 +447,79 @@ class PillsFormatterTests: XCTestCase {
XCTFail("Pill should be of type .message")
}
}
func testInsertPillInMarkdownString() {
let message = "Hello \(Inputs.markdownLinkToBob)"
let messageWithPills = insertPillsInMarkdownString(message)
XCTAssertTrue(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment)
let pillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.bobDisplayname)
}
func testInsertMultiplePillsInMarkdownString() {
let message = "Hello \(Inputs.markdownLinkToBob) and \(Inputs.markdownLinkToAlice)"
let messageWithPills = insertPillsInMarkdownString(message)
let bobPillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(bobPillTextAttachment?.data?.displayText, Inputs.bobDisplayname)
let alicePillTextAttachment = messageWithPills.attribute(.attachment, at: 12, effectiveRange: nil) as? PillTextAttachment
XCTAssertEqual(alicePillTextAttachment?.data?.displayText, Inputs.aliceDisplayname)
// No self highlight
XCTAssert(alicePillTextAttachment?.data?.isHighlighted == false)
}
func testMarkdownLinkToUnknownUserIsNotPillified() {
let message = "Hello [Unknown user](https://matrix.to/#/@unknown:matrix.org)"
let messageWithPills = insertPillsInMarkdownString(message)
XCTAssertFalse(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment)
}
func testMarkdownSingleLinkDetection() {
let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice)")
let expected = [
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!,
label: Inputs.aliceDisplayname,
range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count))
]
XCTAssertEqual(
PillsFormatter.markdownLinks(in: message),
expected
)
}
func testMarkdownMultipleLinksDetection() {
let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice) and \(Inputs.markdownLinkToBob)")
let expected = [
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!,
label: Inputs.aliceDisplayname,
range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)),
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.bobPermalink)!,
label: Inputs.bobDisplayname,
range: NSRange(location: 6 + Inputs.markdownLinkToAlice.count + 5,
length: Inputs.markdownLinkToBob.count))
]
XCTAssertEqual(
PillsFormatter.markdownLinks(in: message),
expected
)
}
func testBrokenMarkdownLinkIsNotDetected() {
let brokenMarkdownMessages = [
NSAttributedString(string: "Hello [Alice](https://matrix.to/#/@alice:matrix.org"),
NSAttributedString(string: "Hello [Alice]https://matrix.to/#/@alice:matrix.org)"),
NSAttributedString(string: "Hello [Alice(https://matrix.to/#/@alice:matrix.org)"),
NSAttributedString(string: "Hello Alice](https://matrix.to/#/@alice:matrix.org)"),
NSAttributedString(string: "Hello [Alice]](https://matrix.to/#/@alice:matrix.org)"),
NSAttributedString(string: "Hello (https://matrix.to/#/@alice:matrix.org)"),
]
for message in brokenMarkdownMessages {
XCTAssertTrue(PillsFormatter.markdownLinks(in: message).isEmpty)
}
}
}
@available(iOS 15.0, *)
@ -604,6 +679,15 @@ private extension PillsFormatterTests {
return messageWithPills
}
private func insertPillsInMarkdownString(_ markdownString: String) -> NSAttributedString {
let message = NSAttributedString(string: markdownString)
let session = FakeMXSession(myUserId: Inputs.aliceUserId)
return PillsFormatter.insertPills(in: message,
withSession: session,
eventFormatter: EventFormatter(matrixSession: session),
roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()),
font: UIFont.systemFont(ofSize: 15.0))
}
}
// MARK: - Mock objects

1
changelog.d/7442.change Normal file
View file

@ -0,0 +1 @@
Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support.

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: 2.0.0
DeviceKit:
url: https://github.com/devicekit/DeviceKit
majorVersion: 4.7.0