From c933526f22552b852cd59c957be3e7a384201074 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 8 Mar 2023 15:16:19 +0100 Subject: [PATCH 01/19] Enable user mentions in Rich Text Editor --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Pills/PillAttachmentViewProvider.swift | 11 ++- Riot/Modules/Pills/PillViewFlusher.swift | 39 ++++++++ Riot/Modules/Pills/PillsFormatter.swift | 69 ++++++++++++++- Riot/Modules/Room/RoomViewController.m | 5 ++ Riot/Modules/Room/RoomViewController.swift | 88 +++++++++++++------ .../Views/InputToolbar/RoomInputToolbarView.h | 5 ++ .../WysiwygInputToolbarView.swift | 37 +++++++- .../Room/Composer/Model/ComposerModels.swift | 11 +++ .../Modules/Room/Composer/View/Composer.swift | 7 ++ .../ViewModel/ComposerViewModel.swift | 2 + .../UserSuggestionCoordinator.swift | 5 ++ .../UserSuggestionCoordinatorBridge.swift | 4 + .../Service/UserSuggestionService.swift | 11 +++ .../UserSuggestionServiceProtocol.swift | 2 + project.yml | 2 +- 16 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 Riot/Modules/Pills/PillViewFlusher.swift diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 44f6bd53ca..751383169d 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index ba03ef61af..07806eddc4 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -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) } } diff --git a/Riot/Modules/Pills/PillViewFlusher.swift b/Riot/Modules/Pills/PillViewFlusher.swift new file mode 100644 index 0000000000..44a4d7cbf8 --- /dev/null +++ b/Riot/Modules/Pills/PillViewFlusher.swift @@ -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 { } diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index a9df99fd46..334a39e73e 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -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) + } } diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 2e641916a7..17ab27fc48 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5149,6 +5149,11 @@ - (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView *)toolbar [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. diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index a177281f36..1da92a731a 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -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)) + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) + + 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 { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + 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(": ") } - newAttributedString.appendString(": ") - } - inputToolbar.attributedTextMessage = newAttributedString - inputToolbar.becomeFirstResponder() + 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()) diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index af84b462dc..ee1a032e02 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -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 /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index db6cc81939..aefef24596 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -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 { diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 98d7febf6d..c964536675 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -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() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 1413912c2a..fb6ed88516 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -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 { diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift index a78018f606..6448b9de33 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -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)) } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index c6d86a6558..59b25ef867 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -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() { } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index c5b68eeee0..4605547ebb 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -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() diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index bf8fa00a5f..0f161ee389 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift index 81edb0df97..43006dbed9 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift @@ -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 diff --git a/project.yml b/project.yml index acc69ccdc7..3df4c94ff9 100644 --- a/project.yml +++ b/project.yml @@ -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 From 3b09fcc0c8082aee26420ff18e45f4e9ca434663 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 8 Mar 2023 17:30:50 +0100 Subject: [PATCH 02/19] Use textDefaultFont in all variants of the `InputToolbarView` --- .../Views/RoomInputToolbar/MXKRoomInputToolbarView.h | 2 ++ .../Views/RoomInputToolbar/MXKRoomInputToolbarView.m | 4 ++++ Riot/Modules/Pills/PillsFormatter.swift | 5 +++-- Riot/Modules/Room/RoomViewController.swift | 10 ++++++---- .../WYSIWYGInputToolbar/WysiwygInputToolbarView.swift | 4 ++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index d7bf9d8fca..bc9b8e0b26 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -382,6 +382,8 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont; + - (void)dismissValidationView:(MXKImageView*)validationView; @end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 9581df2a79..44199cc5bd 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -358,6 +358,10 @@ - (void)pasteText:(NSString *)text self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; } +- (UIFont *)textDefaultFont +{ + return [UIFont systemFontOfSize:15.f]; +} #pragma mark - MXKFileSizes diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index 334a39e73e..4882ad51cd 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -79,8 +79,9 @@ class PillsFormatter: NSObject { /// - 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, roomState: MXRoomState) -> NSAttributedString { + static func insertPills(in markdownString: NSAttributedString, roomState: MXRoomState, font: UIFont) -> NSAttributedString { // Create a regexp that detects markdown links. let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)" guard let regExp = try? NSRegularExpression(pattern: pattern) else { return markdownString } @@ -108,7 +109,7 @@ class PillsFormatter: NSObject { let roomMember = roomMember(withUserId: userId, roomState: roomState, andLatestRoomState: nil) { - let attachmentString = mentionPill(withRoomMember: roomMember, isHighlighted: false, font: UIFont.systemFont(ofSize: 14)) + let attachmentString = mentionPill(withRoomMember: roomMember, isHighlighted: false, font: font) mutable.replaceCharacters(in: match.range, with: attachmentString) } } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 1da92a731a..b36447654a 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -31,7 +31,7 @@ extension RoomViewController { if #available(iOS 15.0, *) { newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, isHighlighted: false, - font: UIFont.systemFont(ofSize: 14))) + font: inputToolbarView.textDefaultFont)) } else { newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) } @@ -42,7 +42,7 @@ extension RoomViewController { if #available(iOS 15.0, *) { newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, isHighlighted: false, - font: UIFont.systemFont(ofSize: 14))) + font: inputToolbarView.textDefaultFont)) } else { newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) } @@ -169,7 +169,9 @@ extension RoomViewController { // 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) + wysiwygInputToolbar.attributedTextMessage = PillsFormatter.insertPills(in: attributedText, + roomState: self.roomDataSource.roomState, + font: self.inputToolbarView.textDefaultFont) } } @@ -281,7 +283,7 @@ extension RoomViewController { return PillsFormatter.mentionPill(withRoomMember: member, isHighlighted: false, - font: UIFont.systemFont(ofSize: 14)) + font: inputToolbarView.textDefaultFont) } @objc func showWaitingOtherParticipantHeader() { diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index aefef24596..1c5a4233ab 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -99,6 +99,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp self.wysiwygViewModel.textView.attributedText = newValue } } + + override var textDefaultFont: UIFont { + return self.wysiwygViewModel.textView.font ?? UIFont.preferredFont(forTextStyle: .body) + } var isMaximised: Bool { wysiwygViewModel.maximised From 732583e4bd75bfb816cf9d38c91bbf99a1d2edfd Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 9 Mar 2023 12:05:02 +0100 Subject: [PATCH 03/19] Bump to version 1.2.2 --- Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- project.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 751383169d..daf871ac64 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "efa0b75e383a8f8a8269b871cbdee3d9a3a99060", - "version" : "1.2.0" + "revision" : "b81654b30f8b22b2d13f17e5e4c843e1fdc1db32", + "version" : "1.2.2" } }, { diff --git a/project.yml b/project.yml index 3df4c94ff9..64ab238d2e 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,7 @@ packages: branch: 0.0.1 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.2.0 + version: 1.2.2 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0 From 185029945540294fe0498d1da7c078ea2d8eca7f Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 9 Mar 2023 12:11:58 +0100 Subject: [PATCH 04/19] Always use preferred font for body --- .../Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 1c5a4233ab..a092a18ea0 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -101,7 +101,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } override var textDefaultFont: UIFont { - return self.wysiwygViewModel.textView.font ?? UIFont.preferredFont(forTextStyle: .body) + return UIFont.preferredFont(forTextStyle: .body) } var isMaximised: Bool { From a23987bce25b0e5814ba72c4e51d45bd727c557a Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 21 Mar 2023 10:26:37 +0100 Subject: [PATCH 05/19] Update composer library to 1.3.0 and apply changes --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Riot/Modules/Room/RoomViewController.swift | 75 +++++++++++-------- .../Views/InputToolbar/RoomInputToolbarView.h | 4 +- .../WysiwygInputToolbarView.swift | 27 +++++-- project.yml | 2 +- 5 files changed, 66 insertions(+), 46 deletions(-) diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index daf871ac64..a087c8ac32 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "b81654b30f8b22b2d13f17e5e4c843e1fdc1db32", - "version" : "1.2.2" + "revision" : "aa98d9b6e4c3d2c4927190c09c5a7e56d08dbfb0", + "version" : "1.3.0" } }, { diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index b36447654a..a51beba3bf 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import HTMLParser import UIKit import WysiwygComposer @@ -38,6 +39,9 @@ extension RoomViewController { newAttributedString.appendString(" ") } else if roomMember.userId == self.mainSession.myUser.userId { newAttributedString.appendString("/me ") + newAttributedString.addAttribute(.font, + value: inputToolbarView.textDefaultFont, + range: .init(location: 0, length: newAttributedString.length)) } else { if #available(iOS 15.0, *) { newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, @@ -155,24 +159,7 @@ extension RoomViewController { @objc func togglePlainTextMode() { RiotSettings.shared.enableWysiwygTextFormatting.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, - font: self.inputToolbarView.textDefaultFont) - } + wysiwygInputToolbar?.textFormattingEnabled.toggle() } @objc func didChangeMaximisedState(_ isMaximised: Bool) { @@ -270,21 +257,6 @@ 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: inputToolbarView.textDefaultFont) - } @objc func showWaitingOtherParticipantHeader() { let controller = VectorHostingController(rootView: RoomWaitingForMembers()) @@ -395,6 +367,43 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { } } +// MARK: - PermalinkReplacer +extension RoomViewController: PermalinkReplacer { + public func replacementForLink(_ url: String, text: String) -> NSAttributedString? { + guard #available(iOS 15.0, *), + let userId = PillsFormatter.userIdFromPermalink(url), + let roomState = roomDataSource.roomState, + let member = PillsFormatter.roomMember(withUserId: userId, + roomState: roomState, + andLatestRoomState: nil) else { + return nil + } + + return PillsFormatter.mentionPill(withRoomMember: member, + isHighlighted: false, + font: inputToolbarView.textDefaultFont) + } + + public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString { + guard #available(iOS 15.0, *), + let roomState = roomDataSource.roomState else { + return attributedString + } + + return PillsFormatter.insertPills(in: attributedString, + roomState: roomState, + font: inputToolbarView.textDefaultFont) + } + + 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() { diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index ee1a032e02..ebbb8305a7 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -60,7 +60,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. @@ -83,8 +83,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; -- (nullable NSAttributedString *)didRequestAttachmentStringForLink: (NSString *)link andDisplayName: (NSString *)displayName; - @end /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index a092a18ea0..78989a23e6 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -45,7 +45,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var hostingViewController: VectorHostingController! private lazy var wysiwygViewModel = WysiwygComposerViewModel( parserStyle: WysiwygInputToolbarView.parserStyle, - permalinkReplacer: self + permalinkReplacer: permalinkReplacer ) /// Compute current HTML parser style for composer. private static var parserStyle: HTMLParserStyle { @@ -73,6 +73,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } // MARK: Public + + override var delegate: MXKRoomInputToolbarViewDelegate! { + didSet { + wysiwygViewModel.permalinkReplacer = permalinkReplacer + } + } override var placeholder: String! { get { @@ -138,6 +144,10 @@ 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() @@ -207,6 +217,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp 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) } ] @@ -443,12 +462,6 @@ 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 { diff --git a/project.yml b/project.yml index 64ab238d2e..ff745767a4 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,7 @@ packages: branch: 0.0.1 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.2.2 + version: 1.3.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0 From 88aac572ccd0a1aa96a2ac5a16080f0f5730528d Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 21 Mar 2023 10:27:08 +0100 Subject: [PATCH 06/19] Fix broken constraint after using fullscreen mode --- Riot/Modules/Room/RoomViewController.xib | 1 + 1 file changed, 1 insertion(+) diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index f33d661bd4..b7a62a8bf2 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -39,6 +39,7 @@ + From 5fb426f77258d68fddbf07cc1303e6057178167b Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 22 Mar 2023 15:49:42 +0100 Subject: [PATCH 07/19] Display user suggestion list in fullscreen mode with shared context from `UserSuggestionCoordinator` --- Riot/Modules/Room/RoomViewController.m | 5 ++ .../Views/InputToolbar/RoomInputToolbarView.h | 3 + .../WysiwygInputToolbarView.swift | 40 ++++++---- .../Composer/MockComposerScreenState.swift | 23 +++++- .../Room/Composer/Model/ComposerModels.swift | 9 +++ .../Modules/Room/Composer/View/Composer.swift | 75 ++++++++++++++----- .../UserSuggestionCoordinator.swift | 18 +++++ .../UserSuggestionCoordinatorBridge.swift | 4 + .../UserSuggestionViewModel.swift | 6 +- .../UserSuggestionViewModelProtocol.swift | 1 + 10 files changed, 148 insertions(+), 36 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 17ab27fc48..e13b5c0383 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5154,6 +5154,11 @@ - (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; } +- (UserSuggestionSharedContext *)userSuggestionContext +{ + return [self.userSuggestionCoordinator sharedContext]; +} + - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView { // Consider opening the action menu as beginning to type and share encryption keys if requested. diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index ebbb8305a7..454134d288 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -22,6 +22,7 @@ @class RoomInputToolbarView; @class LinkActionWrapper; @class SuggestionPatternWrapper; +@class UserSuggestionSharedContext; /** Destination of the message in the composer @@ -83,6 +84,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; +- (UserSuggestionSharedContext *)userSuggestionContext; + @end /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 78989a23e6..d50be4e3a0 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -76,7 +76,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var delegate: MXKRoomInputToolbarViewDelegate! { didSet { - wysiwygViewModel.permalinkReplacer = permalinkReplacer + setComposer() + //wysiwygViewModel.permalinkReplacer = permalinkReplacer } } @@ -134,6 +135,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp var maxCompressedHeight: CGFloat { wysiwygViewModel.maxCompressedHeight } + + var userSuggestionSharedContext: UserSuggestionSharedContext { + return toolbarViewDelegate!.userSuggestionContext() + } // MARK: - Setup @@ -148,23 +153,24 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var permalinkReplacer: PermalinkReplacer? { return (delegate as? PermalinkReplacer) } - - override func awakeFromNib() { - super.awakeFromNib() + + func setComposer() { viewModel = ComposerViewModel( initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) - + 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, + userSuggestionSharedContext: userSuggestionSharedContext, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } @@ -176,13 +182,13 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp 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) @@ -192,7 +198,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) ]) - + cancellables = [ hostingViewController.heightPublisher .removeDuplicates() @@ -206,7 +212,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp .sink { [weak hostingViewController] _ in hostingViewController?.view.setNeedsLayout() }, - + wysiwygViewModel.$maximised .dropFirst() .removeDuplicates() @@ -228,7 +234,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) } ] - + update(theme: ThemeService.shared().theme) registerThemeServiceDidChangeThemeNotification() NotificationCenter.default.addObserver( @@ -246,6 +252,14 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) } + override func awakeFromNib() { + super.awakeFromNib() + + if delegate != nil { + setComposer() + } + } + override func customizeRendering() { super.customizeRendering() self.backgroundColor = .clear diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 35a628d020..b7d20d38ac 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,12 +29,24 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel + let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) + let userSuggestionSharedContext = UserSuggestionSharedContext(context: userSuggestionViewModel.context, + mediaManager: MXMediaManager()) 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 +69,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, + userSuggestionSharedContext: userSuggestionSharedContext, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -70,3 +83,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { ) } } + +private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { + +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index c964536675..6f7bab1652 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -256,3 +256,12 @@ final class SuggestionPatternWrapper: NSObject { super.init() } } + +final class UserSuggestionViewModelWrapper: NSObject { + let userSuggestionViewModel: UserSuggestionViewModel + + init(_ userSuggestionViewModel: UserSuggestionViewModel) { + self.userSuggestionViewModel = userSuggestionViewModel + super.init() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index fb6ed88516..93793fb729 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,6 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel + private let userSuggestionSharedContext: UserSuggestionSharedContext 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: UserSuggestionSharedContext, 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,24 @@ 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.context) + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: userSuggestionSharedContext.mediaManager))) } } + .frame(height: composerHeight) if viewModel.viewState.textFormattingEnabled { HStack(alignment: .center, spacing: 0) { sendMediaButton diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index 59b25ef867..38776fbd3a 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -30,6 +30,19 @@ struct UserSuggestionCoordinatorParameters { let room: MXRoom } +/// 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 data. +final class UserSuggestionSharedContext: NSObject { + let context: UserSuggestionViewModelType.Context + let mediaManager: MXMediaManager + + init(context: UserSuggestionViewModelType.Context, mediaManager: MXMediaManager) { + self.context = context + self.mediaManager = mediaManager + } +} + final class UserSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties @@ -105,6 +118,11 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionHostingController } + func sharedContext() -> UserSuggestionSharedContext { + UserSuggestionSharedContext(context: userSuggestionViewModel.sharedContext, + mediaManager: parameters.mediaManager) + } + // MARK: - Private private func calculateViewHeight() -> CGFloat { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index 4605547ebb..a7615e43f2 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -52,6 +52,10 @@ final class UserSuggestionCoordinatorBridge: NSObject { func toPresentable() -> UIViewController? { userSuggestionCoordinator.toPresentable() } + + func sharedContext() -> UserSuggestionSharedContext { + userSuggestionCoordinator.sharedContext() + } } extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 1e1f490fc0..3999447b7e 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -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 diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift index 1d89ca9b4e..40318c5df1 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -17,5 +17,6 @@ import Foundation protocol UserSuggestionViewModelProtocol { + var sharedContext: UserSuggestionViewModelType.Context { get } var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } } From 2b61b5bc200baa2c4048d90d667ec9d27e3083cb Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 11:47:15 +0100 Subject: [PATCH 08/19] Use `PillProvider` for RTE Pills creation --- Riot/Modules/Pills/PillProvider.swift | 10 +- Riot/Modules/Pills/PillsFormatter.swift | 111 +++++++++++---------- Riot/Modules/Room/RoomViewController.swift | 22 ++-- 3 files changed, 81 insertions(+), 62 deletions(-) diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift index 60363bc47a..ddff3084b1 100644 --- a/Riot/Modules/Pills/PillProvider.swift +++ b/Riot/Modules/Pills/PillProvider.swift @@ -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 { diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index 4882ad51cd..675e824bad 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -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) } @@ -81,36 +81,28 @@ class PillsFormatter: NSObject { /// - 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, roomState: MXRoomState, font: UIFont) -> 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)) + static func insertPills(in markdownString: NSAttributedString, + withSession session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState, + font: UIFont) -> NSAttributedString { + let matches = markdownUrls(in: markdownString) // 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 + guard !matches.isEmpty else { return markdownString } - // 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)]) - } + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) - // 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: font) - mutable.replaceCharacters(in: match.range, with: attachmentString) + let mutable = NSMutableAttributedString(attributedString: markdownString) + + matches.reversed().forEach { (url: URL, label: String, range: NSRange) in + if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label) { + mutable.replaceCharacters(in: range, with: attachmentString) } } @@ -166,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. /// @@ -217,30 +223,35 @@ 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 - } +@available(iOS 15.0, *) +private extension PillsFormatter { + static func markdownUrls(in attributedString: NSAttributedString) -> [(url: URL, label: String, range: NSRange)] { + // Create a regexp that detects markdown links. + let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)" + guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] } - /// 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) + 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 (url: url, label: label, range: match.range) + } else { + return nil + } + } } } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index a51beba3bf..3d9ac22606 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -371,26 +371,30 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { extension RoomViewController: PermalinkReplacer { public func replacementForLink(_ url: String, text: String) -> NSAttributedString? { guard #available(iOS 15.0, *), - let userId = PillsFormatter.userIdFromPermalink(url), - let roomState = roomDataSource.roomState, - let member = PillsFormatter.roomMember(withUserId: userId, - roomState: roomState, - andLatestRoomState: nil) else { + let url = URL(string: url), + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { return nil } - return PillsFormatter.mentionPill(withRoomMember: member, - isHighlighted: false, - font: inputToolbarView.textDefaultFont) + 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 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.textDefaultFont) } From 9c46f607aa4ce59a7d4b5b67a53c8a9bb32f6e4c Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 14:30:17 +0100 Subject: [PATCH 09/19] Avoid crashing if data source is not ready when translating Pills --- Riot/Modules/Room/RoomViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 3d9ac22606..00de9de959 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -387,6 +387,7 @@ extension RoomViewController: PermalinkReplacer { 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 { From 5b2ce259319038c5d2182ed11a6885604bac35a9 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 14:50:49 +0100 Subject: [PATCH 10/19] Clean `WysiwygInputToolbarView` code --- .../WysiwygInputToolbarView.swift | 123 +++++++++--------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index d50be4e3a0..343f020f80 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -43,9 +43,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var heightConstraint: NSLayoutConstraint! private var voiceMessageBottomConstraint: NSLayoutConstraint? private var hostingViewController: VectorHostingController! - private lazy var wysiwygViewModel = WysiwygComposerViewModel( - parserStyle: WysiwygInputToolbarView.parserStyle, - permalinkReplacer: permalinkReplacer + private var wysiwygViewModel = WysiwygComposerViewModel( + parserStyle: WysiwygInputToolbarView.parserStyle ) /// Compute current HTML parser style for composer. private static var parserStyle: HTMLParserStyle { @@ -76,8 +75,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var delegate: MXKRoomInputToolbarViewDelegate! { didSet { - setComposer() - //wysiwygViewModel.permalinkReplacer = permalinkReplacer + setupComposerIfNeeded() } } @@ -135,10 +133,6 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp var maxCompressedHeight: CGFloat { wysiwygViewModel.maxCompressedHeight } - - var userSuggestionSharedContext: UserSuggestionSharedContext { - return toolbarViewDelegate!.userSuggestionContext() - } // MARK: - Setup @@ -153,8 +147,62 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var permalinkReplacer: PermalinkReplacer? { return (delegate as? PermalinkReplacer) } + + override func awakeFromNib() { + super.awakeFromNib() + + setupComposerIfNeeded() + } + + override func customizeRendering() { + super.customizeRendering() + self.backgroundColor = .clear + } + + override func dismissKeyboard() { + self.viewModel.dismissKeyboard() + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + self.wysiwygViewModel.textView.becomeFirstResponder() + } + + override func dismissValidationView(_ validationView: MXKImageView!) { + super.dismissValidationView(validationView) + if isMaximised { + showKeyboard() + } + } + + func showKeyboard() { + self.viewModel.showKeyboard() + } + + func minimise() { + wysiwygViewModel.maximised = false + } + + func performLinkOperation(_ linkOperation: WysiwygLinkOperation) { + if let selectionToRestore = viewModel.selectionToRestore { + wysiwygViewModel.select(range: selectionToRestore) + } + wysiwygViewModel.applyLinkOperation(linkOperation) + } + + func mention(_ member: MXRoomMember) { + self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId), + name: member.displayname, + key: .at) + } + + // MARK: - Private + + private func setupComposerIfNeeded() { + guard hostingViewController == nil, + let toolbarViewDelegate, + let permalinkReplacer else { return } - func setComposer() { viewModel = ComposerViewModel( initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, isLandscapePhone: isLandscapePhone, @@ -164,13 +212,14 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp self?.handleViewModelResult(result) } wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting + wysiwygViewModel.permalinkReplacer = permalinkReplacer inputAccessoryViewForKeyboard = UIView(frame: .zero) let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, - userSuggestionSharedContext: userSuggestionSharedContext, + userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext(), resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } @@ -252,58 +301,6 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) } - override func awakeFromNib() { - super.awakeFromNib() - - if delegate != nil { - setComposer() - } - } - - override func customizeRendering() { - super.customizeRendering() - self.backgroundColor = .clear - } - - override func dismissKeyboard() { - self.viewModel.dismissKeyboard() - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - self.wysiwygViewModel.textView.becomeFirstResponder() - } - - override func dismissValidationView(_ validationView: MXKImageView!) { - super.dismissValidationView(validationView) - if isMaximised { - showKeyboard() - } - } - - func showKeyboard() { - self.viewModel.showKeyboard() - } - - func minimise() { - wysiwygViewModel.maximised = false - } - - func performLinkOperation(_ linkOperation: WysiwygLinkOperation) { - if let selectionToRestore = viewModel.selectionToRestore { - wysiwygViewModel.select(range: selectionToRestore) - } - wysiwygViewModel.applyLinkOperation(linkOperation) - } - - func mention(_ member: MXRoomMember) { - self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId), - name: member.displayname, - key: .at) - } - - // MARK: - Private - @objc private func keyboardWillShow(_ notification: Notification) { if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { let keyboardRectangle = keyboardFrame.cgRectValue From 7b5a46f29ed98f35db2a66fbbaea0136f3e64d11 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 14:51:56 +0100 Subject: [PATCH 11/19] Allow displaying `UserSuggestionList` without shadow --- .../Modules/Room/Composer/View/Composer.swift | 2 +- .../View/UserSuggestionList.swift | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 93793fb729..824e04d73b 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -256,7 +256,7 @@ struct Composer: View { } } if wysiwygViewModel.maximised { - UserSuggestionList(viewModel: userSuggestionSharedContext.context) + UserSuggestionList(viewModel: userSuggestionSharedContext.context, showBackgroundShadow: false) .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: userSuggestionSharedContext.mediaManager))) } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift index 859b0b4145..9c32b892fd 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift @@ -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: View { From b38ba7306f4c219c600f00d80e5ee7d251a86c0f Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 15:02:19 +0100 Subject: [PATCH 12/19] Fix wrong condition for highlight test --- Riot/Modules/Pills/PillProvider.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift index ddff3084b1..1941f8af12 100644 --- a/Riot/Modules/Pills/PillProvider.swift +++ b/Riot/Modules/Pills/PillProvider.swift @@ -135,8 +135,8 @@ struct PillProvider { 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 + // No highlight on self-mentions + && event?.sender != session.myUserId let avatar: PillTextAttachmentItem if roomMember == nil && user == nil { From 052acddc3b1a5f54602c76f8d06db1a4977487f3 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 15:45:31 +0100 Subject: [PATCH 13/19] Update environment object setup and view model context wrapping to restore SwiftUI UI tests --- Riot/Modules/Room/RoomViewController.m | 7 ++++++- .../Views/InputToolbar/RoomInputToolbarView.h | 6 ++++-- .../WysiwygInputToolbarView.swift | 12 +++++++----- .../Room/Composer/MockComposerScreenState.swift | 4 +--- .../Modules/Room/Composer/View/Composer.swift | 7 +++---- .../Coordinator/UserSuggestionCoordinator.swift | 15 +++++---------- .../UserSuggestionCoordinatorBridge.swift | 2 +- .../UserSuggestionViewModelProtocol.swift | 3 +++ 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index e13b5c0383..6fa73faa58 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5154,11 +5154,16 @@ - (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; } -- (UserSuggestionSharedContext *)userSuggestionContext +- (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. diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index 454134d288..897922832d 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -22,7 +22,7 @@ @class RoomInputToolbarView; @class LinkActionWrapper; @class SuggestionPatternWrapper; -@class UserSuggestionSharedContext; +@class UserSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -84,7 +84,9 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; -- (UserSuggestionSharedContext *)userSuggestionContext; +- (UserSuggestionViewModelContextWrapper *)userSuggestionContext; + +- (MXMediaManager *)mediaManager; @end diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 343f020f80..e6b191e2b3 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -219,7 +219,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, - userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext(), + userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } @@ -227,10 +227,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp }, 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 - } + }) + .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 diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index b7d20d38ac..8b5327b14d 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -30,8 +30,6 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) - let userSuggestionSharedContext = UserSuggestionSharedContext(context: userSuggestionViewModel.context, - mediaManager: MXMediaManager()) let bindings = ComposerBindings(focused: false) switch self { @@ -69,7 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, - userSuggestionSharedContext: userSuggestionSharedContext, + userSuggestionSharedContext: userSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 824e04d73b..e4317a2759 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,7 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel - private let userSuggestionSharedContext: UserSuggestionSharedContext + private let userSuggestionSharedContext: UserSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -223,7 +223,7 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, - userSuggestionSharedContext: UserSuggestionSharedContext, + userSuggestionSharedContext: UserSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { @@ -256,8 +256,7 @@ struct Composer: View { } } if wysiwygViewModel.maximised { - UserSuggestionList(viewModel: userSuggestionSharedContext.context, showBackgroundShadow: false) - .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: userSuggestionSharedContext.mediaManager))) + UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false) } } .frame(height: composerHeight) diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index 38776fbd3a..de56c07364 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -30,16 +30,12 @@ struct UserSuggestionCoordinatorParameters { let room: MXRoom } -/// 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 data. -final class UserSuggestionSharedContext: NSObject { +/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. +final class UserSuggestionViewModelContextWrapper: NSObject { let context: UserSuggestionViewModelType.Context - let mediaManager: MXMediaManager - init(context: UserSuggestionViewModelType.Context, mediaManager: MXMediaManager) { + init(context: UserSuggestionViewModelType.Context) { self.context = context - self.mediaManager = mediaManager } } @@ -118,9 +114,8 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionHostingController } - func sharedContext() -> UserSuggestionSharedContext { - UserSuggestionSharedContext(context: userSuggestionViewModel.sharedContext, - mediaManager: parameters.mediaManager) + func sharedContext() -> UserSuggestionViewModelContextWrapper { + UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index a7615e43f2..9dbebdbf33 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -53,7 +53,7 @@ final class UserSuggestionCoordinatorBridge: NSObject { userSuggestionCoordinator.toPresentable() } - func sharedContext() -> UserSuggestionSharedContext { + func sharedContext() -> UserSuggestionViewModelContextWrapper { userSuggestionCoordinator.sharedContext() } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift index 40318c5df1..33aa5bb795 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -17,6 +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 } } From f83599b83ffe323615a4e5e5588da5cb15391728 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 17:12:54 +0100 Subject: [PATCH 14/19] Bump composer version to 1.4.0 --- Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Composer/LinkAction/Model/ComposerLinkActionModel.swift | 2 ++ .../LinkAction/ViewModel/ComposerLinkActionViewModel.swift | 5 +++++ project.yml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index a087c8ac32..75c0b64386 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "aa98d9b6e4c3d2c4927190c09c5a7e56d08dbfb0", - "version" : "1.3.0" + "revision" : "ca2f6508bcd8ec0ce239a48347ff155a3a7bef06", + "version" : "1.4.0" } }, { diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift index fdf92cab5d..0c3ba03e2b 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift @@ -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 } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift index 9683ac6214..3674172823 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift @@ -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 } } } diff --git a/project.yml b/project.yml index ff745767a4..bb958c6da5 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,7 @@ packages: branch: 0.0.1 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.3.0 + version: 1.4.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0 From 3f9d6540dd009135fe5b45f506a6211d4e9d6c65 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Thu, 23 Mar 2023 17:15:27 +0100 Subject: [PATCH 15/19] Add changelog --- changelog.d/7442.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7442.change diff --git a/changelog.d/7442.change b/changelog.d/7442.change new file mode 100644 index 0000000000..aeb75b57d0 --- /dev/null +++ b/changelog.d/7442.change @@ -0,0 +1 @@ +Labs: Rich Text Editor: Integrate version 1.4.0 with mention Pills support. From 9d55eb0ce656d6fcbb9e5cc3f7ea0d261d1eb828 Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 11 Apr 2023 14:28:48 +0200 Subject: [PATCH 16/19] Bump composer version to 2.0.0 and fix `PillAttachmentViewProvider` --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Pills/PillAttachmentViewProvider.swift | 24 +++++++++++-------- .../WysiwygInputToolbarView.swift | 2 +- project.yml | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 75c0b64386..9870eb6da0 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "ca2f6508bcd8ec0ce239a48347ff155a3a7bef06", - "version" : "1.4.0" + "revision" : "758f226a92d6726ab626c1e78ecd183bdba77016", + "version" : "2.0.0" } }, { diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index e47331a36c..5d57bb3b27 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -25,30 +25,29 @@ import UIKit avatarLeading: 2.0, avatarSideLength: 16.0, itemSpacing: 4) - private weak var pillViewFlusher: PillViewFlusher? + 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) - // 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") - } + // 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 } @@ -64,6 +63,11 @@ import UIKit mediaManager: mainSession?.mediaManager, andPillData: pillData) view = pillView - pillViewFlusher?.registerPillView(pillView) + + if let pillViewFlusher { + pillViewFlusher.registerPillView(pillView) + } else { + MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly") + } } } diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index e6b191e2b3..cb9a162b3b 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -193,7 +193,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp func mention(_ member: MXRoomMember) { self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId), name: member.displayname, - key: .at) + mentionType: .user) } // MARK: - Private diff --git a/project.yml b/project.yml index bb958c6da5..6a207706dd 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,7 @@ packages: branch: 0.0.1 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.4.0 + version: 2.0.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0 From f9623e351e3e68b7e92fbca6608dac5e8a29946a Mon Sep 17 00:00:00 2001 From: aringenbach Date: Tue, 11 Apr 2023 14:54:55 +0200 Subject: [PATCH 17/19] Rename `textDefaultFont` to `defaultFont` and remove unnecessary definition in `RoomInputToolbarView.h` --- .../Views/RoomInputToolbar/MXKRoomInputToolbarView.h | 5 ++++- .../Views/RoomInputToolbar/MXKRoomInputToolbarView.m | 2 +- Riot/Modules/Room/RoomViewController.swift | 8 ++++---- .../Room/Views/InputToolbar/RoomInputToolbarView.h | 2 -- .../Room/Views/InputToolbar/RoomInputToolbarView.m | 4 ++-- .../WYSIWYGInputToolbar/WysiwygInputToolbarView.swift | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index bc9b8e0b26..e366ae2393 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -382,7 +382,10 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; -@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont; +/** + Default font for the message composer. + */ +@property (nonatomic, readonly, nonnull) UIFont *defaultFont; - (void)dismissValidationView:(MXKImageView*)validationView; diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 44199cc5bd..d05cd9f53c 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -358,7 +358,7 @@ - (void)pasteText:(NSString *)text self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; } -- (UIFont *)textDefaultFont +- (UIFont *)defaultFont { return [UIFont systemFontOfSize:15.f]; } diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 00de9de959..3fec13de94 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -32,7 +32,7 @@ extension RoomViewController { if #available(iOS 15.0, *) { newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, isHighlighted: false, - font: inputToolbarView.textDefaultFont)) + font: inputToolbarView.defaultFont)) } else { newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) } @@ -40,13 +40,13 @@ extension RoomViewController { } else if roomMember.userId == self.mainSession.myUser.userId { newAttributedString.appendString("/me ") newAttributedString.addAttribute(.font, - value: inputToolbarView.textDefaultFont, + 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.textDefaultFont)) + font: inputToolbarView.defaultFont)) } else { newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) } @@ -397,7 +397,7 @@ extension RoomViewController: PermalinkReplacer { withSession: session, eventFormatter: eventFormatter, roomState: roomState, - font: inputToolbarView.textDefaultFont) + font: inputToolbarView.defaultFont) } public func restoreMarkdown(in attributedString: NSAttributedString) -> String { diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index 897922832d..df71790bed 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -136,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 */ diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index 9abfde4213..2cead382a0 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -154,7 +154,7 @@ - (void)setAttributedTextMessage:(NSAttributedString *)attributedTextMessage { 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 @@ - (NSString *)textMessage return self.textView.text; } -- (UIFont *)textDefaultFont +- (UIFont *)defaultFont { if (self.textView.font) { diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index cb9a162b3b..f3fc1111bd 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -105,7 +105,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } } - override var textDefaultFont: UIFont { + override var defaultFont: UIFont { return UIFont.preferredFont(forTextStyle: .body) } From 524af383db84cad158316d632c8f2aaa20dbdefb Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 12 Apr 2023 14:55:59 +0200 Subject: [PATCH 18/19] Unit tests for `insertPills` and `markdownLinks` --- Riot/Modules/Pills/PillsFormatter.swift | 37 +++++----- RiotTests/PillsFormatterTests.swift | 90 ++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index 675e824bad..1b6256835a 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -86,7 +86,7 @@ class PillsFormatter: NSObject { eventFormatter: MXKEventFormatter, roomState: MXRoomState, font: UIFont) -> NSAttributedString { - let matches = markdownUrls(in: markdownString) + let matches = markdownLinks(in: markdownString) // If we have some matches, replace permalinks by a pill version. guard !matches.isEmpty else { return markdownString } @@ -100,9 +100,9 @@ class PillsFormatter: NSObject { let mutable = NSMutableAttributedString(attributedString: markdownString) - matches.reversed().forEach { (url: URL, label: String, range: NSRange) in - if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label) { - mutable.replaceCharacters(in: range, with: attachmentString) + matches.reversed().forEach { + if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) { + mutable.replaceCharacters(in: $0.range, with: attachmentString) } } @@ -214,21 +214,15 @@ class PillsFormatter: NSObject { // MARK: - Private Methods @available (iOS 15.0, *) extension PillsFormatter { - - static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString { - let string = NSMutableAttributedString(attachment: attachment) - string.addAttribute(.font, value: font, range: .init(location: 0, length: string.length)) - if let url = link { - string.addAttribute(.link, value: url, range: .init(location: 0, length: string.length)) - } - return string + struct MarkdownLinkResult: Equatable { + let url: URL + let label: String + let range: NSRange } -} -@available(iOS 15.0, *) -private extension PillsFormatter { - static func markdownUrls(in attributedString: NSAttributedString) -> [(url: URL, label: String, 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 [] } @@ -248,10 +242,19 @@ private extension PillsFormatter { } if let url = URL(string: url) { - return (url: url, label: label, range: match.range) + 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) + string.addAttribute(.font, value: font, range: .init(location: 0, length: string.length)) + if let url = link { + string.addAttribute(.link, value: url, range: .init(location: 0, length: string.length)) + } + return string + } } diff --git a/RiotTests/PillsFormatterTests.swift b/RiotTests/PillsFormatterTests.swift index 573fd234c7..a520117763 100644 --- a/RiotTests/PillsFormatterTests.swift +++ b/RiotTests/PillsFormatterTests.swift @@ -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 From c1abd2a0ab2ca5c9d1b66cc90e87ff65255b154e Mon Sep 17 00:00:00 2001 From: aringenbach Date: Wed, 12 Apr 2023 14:56:33 +0200 Subject: [PATCH 19/19] Update changelog --- changelog.d/7442.change | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/7442.change b/changelog.d/7442.change index aeb75b57d0..f8ae96d5b2 100644 --- a/changelog.d/7442.change +++ b/changelog.d/7442.change @@ -1 +1 @@ -Labs: Rich Text Editor: Integrate version 1.4.0 with mention Pills support. +Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support.