diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 44f6bd53ca..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" : "addf90f3e2a6ab46bd2b2febe117d9cddb646e7d", - "version" : "1.1.1" + "revision" : "758f226a92d6726ab626c1e78ecd183bdba77016", + "version" : "2.0.0" } }, { diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index d7bf9d8fca..e366ae2393 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -382,6 +382,11 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +/** + Default font for the message composer. + */ +@property (nonatomic, readonly, nonnull) UIFont *defaultFont; + - (void)dismissValidationView:(MXKImageView*)validationView; @end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 9581df2a79..d05cd9f53c 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 *)defaultFont +{ + return [UIFont systemFontOfSize:15.f]; +} #pragma mark - MXKFileSizes diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index ae02b019f6..5d57bb3b27 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -25,25 +25,29 @@ import UIKit avatarLeading: 2.0, avatarSideLength: 16.0, itemSpacing: 4) - private weak var messageTextView: MXKMessageTextView? + private weak var messageTextView: UITextView? + private var pillViewFlusher: PillViewFlusher? { + messageTextView as? PillViewFlusher + } // MARK: - Override override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) { super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location) - self.messageTextView = parentView?.superview as? MXKMessageTextView + // Keep a reference to the parent text view for size adjustments and pills flushing. + messageTextView = parentView?.superview as? UITextView } override func loadView() { super.loadView() guard let textAttachment = self.textAttachment as? PillTextAttachment else { - MXLog.debug("[PillAttachmentViewProvider]: attachment is missing or not of expected class") + MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class") return } guard var pillData = textAttachment.data else { - MXLog.debug("[PillAttachmentViewProvider]: attachment misses pill data") + MXLog.failure("[PillAttachmentViewProvider]: attachment misses pill data") return } @@ -59,6 +63,11 @@ import UIKit mediaManager: mainSession?.mediaManager, andPillData: pillData) view = pillView - messageTextView?.registerPillView(pillView) + + if let pillViewFlusher { + pillViewFlusher.registerPillView(pillView) + } else { + MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly") + } } } diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift index 60363bc47a..1941f8af12 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/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..1b6256835a 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) } @@ -74,6 +74,41 @@ class PillsFormatter: NSObject { return newAttr } + /// Insert text attachments for pills inside given attributed string containing markdown. + /// + /// - Parameters: + /// - markdownString: An attributed string with markdown formatting + /// - roomState: The current room state + /// - font: The font to use for the pill text + /// - Returns: A new attributed string with pills. + static func insertPills(in markdownString: NSAttributedString, + withSession session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState, + font: UIFont) -> NSAttributedString { + let matches = markdownLinks(in: markdownString) + + // If we have some matches, replace permalinks by a pill version. + guard !matches.isEmpty else { return markdownString } + + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + + let mutable = NSMutableAttributedString(attributedString: markdownString) + + matches.reversed().forEach { + if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) { + mutable.replaceCharacters(in: $0.range, with: attachmentString) + } + } + + return mutable + } + /// Creates a string with all pills of given attributed string replaced by display names. /// /// - Parameters: @@ -123,6 +158,20 @@ class PillsFormatter: NSObject { } return attributedStringWithAttachment(attachment, link: url, font: font) } + + static func mentionPill(withUrl url: URL, + andLabel label: String, + session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState) -> NSAttributedString? { + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + return pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label) + } /// Update alpha of all `PillTextAttachment` contained in given attributed string. /// @@ -160,12 +209,45 @@ class PillsFormatter: NSObject { } } } - } // MARK: - Private Methods @available (iOS 15.0, *) extension PillsFormatter { + struct MarkdownLinkResult: Equatable { + let url: URL + let label: String + let range: NSRange + } + + static func markdownLinks(in attributedString: NSAttributedString) -> [MarkdownLinkResult] { + // Create a regexp that detects markdown links. + // Pattern source: https://gist.github.com/hugocf/66d6cd241eff921e0e02 + let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)" + guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] } + + let matches = regExp.matches(in: attributedString.string, + range: .init(location: 0, length: attributedString.length)) + + return matches.compactMap { match in + let labelRange = match.range(at: 1) + let urlRange = match.range(at: 2) + let label = attributedString.attributedSubstring(from: labelRange).string + var url = attributedString.attributedSubstring(from: urlRange).string + + // Note: a valid markdown link can be written with + // enclosing <..>, remove them for userId detection. + if url.first == "<" && url.last == ">" { + url = String(url[url.index(after: url.startIndex)...url.index(url.endIndex, offsetBy: -2)]) + } + + if let url = URL(string: url) { + return MarkdownLinkResult(url: url, label: label, range: match.range) + } else { + return nil + } + } + } static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString { let string = NSMutableAttributedString(attachment: attachment) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 9966700813..398e12e8f7 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5150,6 +5150,21 @@ - (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView *)toolbar [self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage]; } +- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern +{ + [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; +} + +- (UserSuggestionViewModelContextWrapper *)userSuggestionContext +{ + return [self.userSuggestionCoordinator sharedContext]; +} + +- (MXMediaManager *)mediaManager +{ + return self.roomDataSource.mxSession.mediaManager; +} + - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView { // Consider opening the action menu as beginning to type and share encryption keys if requested. diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index a177281f36..3fec13de94 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -14,46 +14,52 @@ // limitations under the License. // +import HTMLParser import UIKit import WysiwygComposer extension RoomViewController { // MARK: - Override open override func mention(_ roomMember: MXRoomMember) { - guard let inputToolbar = inputToolbar else { - return - } - - let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage) - - if inputToolbar.attributedTextMessage.length > 0 { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) - } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) - } - newAttributedString.appendString(" ") - } else if roomMember.userId == self.mainSession.myUser.userId { - newAttributedString.appendString("/me ") + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.mention(roomMember) + wysiwygInputToolbar.becomeFirstResponder() } else { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) + 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: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(" ") + } else if roomMember.userId == self.mainSession.myUser.userId { + newAttributedString.appendString("/me ") + newAttributedString.addAttribute(.font, + value: inputToolbarView.defaultFont, + range: .init(location: 0, length: newAttributedString.length)) } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + if #available(iOS 15.0, *) { + newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, + isHighlighted: false, + font: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(": ") } - 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 @@ -361,6 +367,48 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { } } +// MARK: - PermalinkReplacer +extension RoomViewController: PermalinkReplacer { + public func replacementForLink(_ url: String, text: String) -> NSAttributedString? { + guard #available(iOS 15.0, *), + let url = URL(string: url), + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return nil + } + + return PillsFormatter.mentionPill(withUrl: url, + andLabel: text, + session: session, + eventFormatter: eventFormatter, + roomState: roomState) + } + + public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString { + guard #available(iOS 15.0, *), + let roomDataSource, + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return attributedString + } + return PillsFormatter.insertPills(in: attributedString, + withSession: session, + eventFormatter: eventFormatter, + roomState: roomState, + font: inputToolbarView.defaultFont) + } + + public func restoreMarkdown(in attributedString: NSAttributedString) -> String { + if #available(iOS 15.0, *) { + return PillsFormatter.stringByReplacingPills(in: attributedString, mode: .markdown) + } else { + return attributedString.string + } + } +} + // MARK: - VoiceBroadcast extension RoomViewController { @objc func stopUncompletedVoiceBroadcastIfNeeded() { 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 @@ + diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index af84b462dc..df71790bed 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -21,6 +21,8 @@ @class RoomActionsBar; @class RoomInputToolbarView; @class LinkActionWrapper; +@class SuggestionPatternWrapper; +@class UserSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -59,7 +61,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) @param toolbarView the room input toolbar view */ -- (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView*)toolbarView; +- (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView*)toolbarView; /** Inform the delegate that the action menu was opened. @@ -80,6 +82,12 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didSendLinkAction: (LinkActionWrapper *)linkAction; +- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; + +- (UserSuggestionViewModelContextWrapper *)userSuggestionContext; + +- (MXMediaManager *)mediaManager; + @end /** @@ -128,8 +136,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) */ @property (nonatomic, weak, readonly) UIButton *attachMediaButton; -@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont; - /** Adds a voice message toolbar view to be displayed inside this input toolbar */ 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 db6cc81939..f3fc1111bd 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } // MARK: Public + + override var delegate: MXKRoomInputToolbarViewDelegate! { + didSet { + setupComposerIfNeeded() + } + } override var placeholder: String! { get { @@ -85,6 +91,23 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var isFocused: Bool { viewModel.isFocused } + + override var attributedTextMessage: NSAttributedString? { + // Note: this is only interactive in plain text mode. If RTE is enabled, + // APIs from the composer view model should be used. + get { + guard !self.textFormattingEnabled else { return nil } + return self.wysiwygViewModel.textView.attributedText + } + set { + guard !self.textFormattingEnabled else { return } + self.wysiwygViewModel.textView.attributedText = newValue + } + } + + override var defaultFont: UIFont { + return UIFont.preferredFont(forTextStyle: .body) + } var isMaximised: Bool { wysiwygViewModel.maximised @@ -120,23 +143,83 @@ 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() + + 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, + mentionType: .user) + } + + // MARK: - Private + + private func setupComposerIfNeeded() { + guard hostingViewController == nil, + let toolbarViewDelegate, + let permalinkReplacer else { return } + viewModel = ComposerViewModel( initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) - + isLandscapePhone: isLandscapePhone, + bindings: ComposerBindings(focused: false))) + viewModel.callback = { [weak self] result in self?.handleViewModelResult(result) } wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting - + wysiwygViewModel.permalinkReplacer = permalinkReplacer + inputAccessoryViewForKeyboard = UIView(frame: .zero) - + let composer = Composer( viewModel: viewModel.context, wysiwygViewModel: wysiwygViewModel, + userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context, resizeAnimationDuration: Double(kResizeComposerAnimationDuration), sendMessageAction: { [weak self] content in guard let self = self else { return } @@ -144,17 +227,19 @@ 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 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) @@ -164,7 +249,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) ]) - + cancellables = [ hostingViewController.heightPublisher .removeDuplicates() @@ -178,7 +263,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp .sink { [weak hostingViewController] _ in hostingViewController?.view.setNeedsLayout() }, - + wysiwygViewModel.$maximised .dropFirst() .removeDuplicates() @@ -189,9 +274,18 @@ 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) } ] - + update(theme: ThemeService.shared().theme) registerThemeServiceDidChangeThemeNotification() NotificationCenter.default.addObserver( @@ -209,39 +303,6 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) } - override func customizeRendering() { - super.customizeRendering() - self.backgroundColor = .clear - } - - override func dismissKeyboard() { - self.viewModel.dismissKeyboard() - } - - 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) - } - - // MARK: - Private - @objc private func keyboardWillShow(_ notification: Notification) { if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { let keyboardRectangle = keyboardFrame.cgRectValue @@ -291,6 +352,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp setVoiceMessageToolbarIsHidden(!isEmpty) case let .linkTapped(linkAction): toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction)) + case let .suggestion(pattern): + toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern)) } } 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/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 35a628d020..8b5327b14d 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,12 +29,22 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel + let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) let bindings = ComposerBindings(focused: false) switch self { - case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", + sendMode: .reply, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) } let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360) @@ -57,6 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, + userSuggestionSharedContext: userSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -70,3 +81,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { ) } } + +private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { + +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 98d7febf6d..6f7bab1652 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,21 @@ final class LinkActionWrapper: NSObject { super.init() } } + +final class SuggestionPatternWrapper: NSObject { + let suggestionPattern: SuggestionPattern? + + init(_ suggestionPattern: SuggestionPattern?) { + self.suggestionPattern = suggestionPattern + super.init() + } +} + +final class UserSuggestionViewModelWrapper: NSObject { + let userSuggestionViewModel: UserSuggestionViewModel + + init(_ userSuggestionViewModel: UserSuggestionViewModel) { + self.userSuggestionViewModel = userSuggestionViewModel + super.init() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 1413912c2a..e4317a2759 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: UserSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -31,15 +32,42 @@ struct Composer: View { @Environment(\.theme) private var theme: ThemeSwiftUI @State private var isActionButtonShowing = false - + private let horizontalPadding: CGFloat = 12 private let borderHeight: CGFloat = 40 - private var verticalPadding: CGFloat { + private let standardVerticalPadding: CGFloat = 8.0 + private let contextBannerHeight: CGFloat = 14.5 + + /// Spacing applied within the VStack holding the context banner and the composer text view. + private let verticalComponentSpacing: CGFloat = 12.0 + /// Padding for the main composer text view. Always applied on bottom. + /// Applied on top only if no context banner is present. + private var composerVerticalPadding: CGFloat { (borderHeight - wysiwygViewModel.minHeight) / 2 } - - private var topPadding: CGFloat { - viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding + + /// Computes the top padding to apply on the composer text view depending on context. + private var composerTopPadding: CGFloat { + viewModel.viewState.shouldDisplayContext ? 0 : composerVerticalPadding + } + + /// Computes the additional height required to display the context banner. + /// Returns 0.0 if the banner is not displayed. + /// Note: height of the actual banner + its added standard top padding + VStack spacing + private var additionalHeightForContextBanner: CGFloat { + viewModel.viewState.shouldDisplayContext ? contextBannerHeight + standardVerticalPadding + verticalComponentSpacing : 0 + } + + /// Computes the total height of the composer (excluding the RTE formatting bar). + /// This height includes the text view, as well as the context banner + /// and user suggestion list when displayed. + private var composerHeight: CGFloat { + wysiwygViewModel.idealHeight + + composerTopPadding + + composerVerticalPadding + // Extra padding added on top of the VStack containing the composer + + standardVerticalPadding + + additionalHeightForContextBanner } private var cornerRadius: CGFloat { @@ -84,7 +112,7 @@ struct Composer: View { private var composerContainer: some View { let rect = RoundedRectangle(cornerRadius: cornerRadius) - return VStack(spacing: 12) { + return VStack(spacing: verticalComponentSpacing) { if viewModel.viewState.shouldDisplayContext { HStack { if let imageName = viewModel.viewState.contextImageName { @@ -106,7 +134,8 @@ struct Composer: View { } .accessibilityIdentifier("cancelButton") } - .padding(.top, 8) + .frame(height: contextBannerHeight) + .padding(.top, standardVerticalPadding) .padding(.horizontal, horizontalPadding) } HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) { @@ -116,7 +145,6 @@ struct Composer: View { ) .tintColor(theme.colors.accent) .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) - .frame(height: wysiwygViewModel.idealHeight) .onAppear { if wysiwygViewModel.isContentEmpty { wysiwygViewModel.setup() @@ -137,13 +165,13 @@ struct Composer: View { } } .padding(.horizontal, horizontalPadding) - .padding(.top, topPadding) - .padding(.bottom, verticalPadding) + .padding(.top, composerTopPadding) + .padding(.bottom, composerVerticalPadding) } .clipShape(rect) .overlay(rect.stroke(borderColor, lineWidth: 1)) .animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight) - .padding(.top, 8) + .padding(.top, standardVerticalPadding) .onTapGesture { if viewModel.focused { viewModel.focused = true @@ -195,11 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, + userSuggestionSharedContext: UserSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel + self.userSuggestionSharedContext = userSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -213,17 +243,23 @@ struct Composer: View { .frame(width: 36, height: 5) .padding(.top, 10) } - HStack(alignment: .bottom, spacing: 0) { - if !viewModel.viewState.textFormattingEnabled { - sendMediaButton - .padding(.bottom, 1) + VStack { + HStack(alignment: .bottom, spacing: 0) { + if !viewModel.viewState.textFormattingEnabled { + sendMediaButton + .padding(.bottom, 1) + } + composerContainer + if !viewModel.viewState.textFormattingEnabled { + sendButton + .padding(.bottom, 1) + } } - composerContainer - if !viewModel.viewState.textFormattingEnabled { - sendButton - .padding(.bottom, 1) + if wysiwygViewModel.maximised { + UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false) } } + .frame(height: composerHeight) if viewModel.viewState.textFormattingEnabled { HStack(alignment: .center, spacing: 0) { sendMediaButton @@ -248,6 +284,9 @@ struct Composer: View { wysiwygViewModel.maximised = false } } + .onChange(of: wysiwygViewModel.suggestionPattern) { newValue in + sendMentionPattern(pattern: newValue) + } } private func storeCurrentSelection() { @@ -258,6 +297,10 @@ struct Composer: View { let linkAction = wysiwygViewModel.getLinkAction() viewModel.send(viewAction: .linkTapped(linkAction: linkAction)) } + + private func sendMentionPattern(pattern: SuggestionPattern?) { + viewModel.send(viewAction: .suggestion(pattern: pattern)) + } } private extension WysiwygComposerViewModel { 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 cc3f208c39..a2156cd89a 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?) @@ -31,6 +32,15 @@ struct UserSuggestionCoordinatorParameters { let userID: String } +/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. +final class UserSuggestionViewModelContextWrapper: NSObject { + let context: UserSuggestionViewModelType.Context + + init(context: UserSuggestionViewModelType.Context) { + self.context = context + } +} + final class UserSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties @@ -99,6 +109,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionService.processTextMessage(textMessage) } + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + userSuggestionService.processSuggestionPattern(suggestionPattern) + } + // MARK: - Public func start() { } @@ -107,6 +121,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionHostingController } + func sharedContext() -> UserSuggestionViewModelContextWrapper { + UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) + } + // MARK: - Private private func calculateViewHeight() -> CGFloat { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index ea81631063..0d1f6795e6 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -45,10 +45,18 @@ final class UserSuggestionCoordinatorBridge: NSObject { func processTextMessage(_ textMessage: String) { userSuggestionCoordinator.processTextMessage(textMessage) } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } func toPresentable() -> UIViewController? { userSuggestionCoordinator.toPresentable() } + + func sharedContext() -> UserSuggestionViewModelContextWrapper { + userSuggestionCoordinator.sharedContext() + } } extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index 2bd8a45692..a790e28458 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 @@ -91,6 +92,16 @@ class UserSuggestionService: UserSuggestionServiceProtocol { currentTextTriggerSubject.send(lastComponent) } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + guard let suggestionPattern, suggestionPattern.key == .at else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + currentTextTriggerSubject.send("@" + suggestionPattern.text) + } // MARK: - Private 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/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..33aa5bb795 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -17,5 +17,9 @@ import Foundation protocol UserSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple + /// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` + /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. + var sharedContext: UserSuggestionViewModelType.Context { get } var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } } 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 { 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 diff --git a/changelog.d/7442.change b/changelog.d/7442.change new file mode 100644 index 0000000000..f8ae96d5b2 --- /dev/null +++ b/changelog.d/7442.change @@ -0,0 +1 @@ +Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support. diff --git a/project.yml b/project.yml index acc69ccdc7..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.1.1 + version: 2.0.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0