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