mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 15:22:39 +00:00
Merge pull request #7416 from vector-im/aringenbach/enable_rte_user_mentions
Enable user mentions in Rich Text Editor
This commit is contained in:
commit
c1a9f31ded
29 changed files with 679 additions and 175 deletions
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -358,6 +358,10 @@
|
|||
self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text];
|
||||
}
|
||||
|
||||
- (UIFont *)defaultFont
|
||||
{
|
||||
return [UIFont systemFontOfSize:15.f];
|
||||
}
|
||||
|
||||
#pragma mark - MXKFileSizes
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
39
Riot/Modules/Pills/PillViewFlusher.swift
Normal file
39
Riot/Modules/Pills/PillViewFlusher.swift
Normal file
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WysiwygComposer
|
||||
|
||||
/// Defines behaviour for an object that is able to manage views created
|
||||
/// by a `NSTextAttachmentViewProvider`. This can be implemented
|
||||
/// by an `UITextView` that would keep track of views in order to
|
||||
/// (internally) clear them when required (e.g. when setting a new attributed text).
|
||||
///
|
||||
/// Note: It is necessary to clear views manually due to a bug in iOS. See `MXKMessageTextView`.
|
||||
@available(iOS 15.0, *)
|
||||
protocol PillViewFlusher: AnyObject {
|
||||
/// Register a pill view that has been added through `NSTextAttachmentViewProvider`.
|
||||
/// Should be called within the `loadView` function in order to clear the pills properly on text updates.
|
||||
///
|
||||
/// - Parameter pillView: View to register.
|
||||
func registerPillView(_ pillView: UIView)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
extension MXKMessageTextView: PillViewFlusher { }
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
extension WysiwygTextView: PillViewFlusher { }
|
|
@ -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)
|
||||
|
|
|
@ -5150,6 +5150,21 @@ static CGSize kThreadListBarButtonItemImageSize;
|
|||
[self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage];
|
||||
}
|
||||
|
||||
- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern
|
||||
{
|
||||
[self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern];
|
||||
}
|
||||
|
||||
- (UserSuggestionViewModelContextWrapper *)userSuggestionContext
|
||||
{
|
||||
return [self.userSuggestionCoordinator sharedContext];
|
||||
}
|
||||
|
||||
- (MXMediaManager *)mediaManager
|
||||
{
|
||||
return self.roomDataSource.mxSession.mediaManager;
|
||||
}
|
||||
|
||||
- (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView
|
||||
{
|
||||
// Consider opening the action menu as beginning to type and share encryption keys if requested.
|
||||
|
|
|
@ -14,46 +14,52 @@
|
|||
// limitations under the License.
|
||||
//
|
||||
|
||||
import HTMLParser
|
||||
import UIKit
|
||||
import WysiwygComposer
|
||||
|
||||
extension RoomViewController {
|
||||
// MARK: - Override
|
||||
open override func mention(_ roomMember: MXRoomMember) {
|
||||
guard let inputToolbar = inputToolbar else {
|
||||
return
|
||||
}
|
||||
|
||||
let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage)
|
||||
|
||||
if inputToolbar.attributedTextMessage.length > 0 {
|
||||
if #available(iOS 15.0, *) {
|
||||
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
|
||||
isHighlighted: false,
|
||||
font: inputToolbar.textDefaultFont))
|
||||
} else {
|
||||
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
|
||||
}
|
||||
newAttributedString.appendString(" ")
|
||||
} else if roomMember.userId == self.mainSession.myUser.userId {
|
||||
newAttributedString.appendString("/me ")
|
||||
if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled {
|
||||
wysiwygInputToolbar.mention(roomMember)
|
||||
wysiwygInputToolbar.becomeFirstResponder()
|
||||
} else {
|
||||
if #available(iOS 15.0, *) {
|
||||
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
|
||||
isHighlighted: false,
|
||||
font: inputToolbar.textDefaultFont))
|
||||
} else {
|
||||
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
|
||||
}
|
||||
newAttributedString.appendString(": ")
|
||||
}
|
||||
guard let attributedText = inputToolbarView.attributedTextMessage else { return }
|
||||
let newAttributedString = NSMutableAttributedString(attributedString: attributedText)
|
||||
|
||||
inputToolbar.attributedTextMessage = newAttributedString
|
||||
inputToolbar.becomeFirstResponder()
|
||||
if attributedText.length > 0 {
|
||||
if #available(iOS 15.0, *) {
|
||||
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
|
||||
isHighlighted: false,
|
||||
font: inputToolbarView.defaultFont))
|
||||
} else {
|
||||
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
|
||||
}
|
||||
newAttributedString.appendString(" ")
|
||||
} else if roomMember.userId == self.mainSession.myUser.userId {
|
||||
newAttributedString.appendString("/me ")
|
||||
newAttributedString.addAttribute(.font,
|
||||
value: inputToolbarView.defaultFont,
|
||||
range: .init(location: 0, length: newAttributedString.length))
|
||||
} else {
|
||||
if #available(iOS 15.0, *) {
|
||||
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember,
|
||||
isHighlighted: false,
|
||||
font: inputToolbarView.defaultFont))
|
||||
} else {
|
||||
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
|
||||
}
|
||||
newAttributedString.appendString(": ")
|
||||
}
|
||||
|
||||
inputToolbarView.attributedTextMessage = newAttributedString
|
||||
inputToolbarView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Send the formatted text message and its raw counterpat to the room
|
||||
/// Send the formatted text message and its raw counterpart to the room
|
||||
///
|
||||
/// - Parameter rawTextMsg: the raw text message
|
||||
/// - Parameter htmlMsg: the html text message
|
||||
|
@ -361,6 +367,48 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - PermalinkReplacer
|
||||
extension RoomViewController: PermalinkReplacer {
|
||||
public func replacementForLink(_ url: String, text: String) -> NSAttributedString? {
|
||||
guard #available(iOS 15.0, *),
|
||||
let url = URL(string: url),
|
||||
let session = roomDataSource.mxSession,
|
||||
let eventFormatter = roomDataSource.eventFormatter,
|
||||
let roomState = roomDataSource.roomState else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return PillsFormatter.mentionPill(withUrl: url,
|
||||
andLabel: text,
|
||||
session: session,
|
||||
eventFormatter: eventFormatter,
|
||||
roomState: roomState)
|
||||
}
|
||||
|
||||
public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString {
|
||||
guard #available(iOS 15.0, *),
|
||||
let roomDataSource,
|
||||
let session = roomDataSource.mxSession,
|
||||
let eventFormatter = roomDataSource.eventFormatter,
|
||||
let roomState = roomDataSource.roomState else {
|
||||
return attributedString
|
||||
}
|
||||
return PillsFormatter.insertPills(in: attributedString,
|
||||
withSession: session,
|
||||
eventFormatter: eventFormatter,
|
||||
roomState: roomState,
|
||||
font: inputToolbarView.defaultFont)
|
||||
}
|
||||
|
||||
public func restoreMarkdown(in attributedString: NSAttributedString) -> String {
|
||||
if #available(iOS 15.0, *) {
|
||||
return PillsFormatter.stringByReplacingPills(in: attributedString, mode: .markdown)
|
||||
} else {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceBroadcast
|
||||
extension RoomViewController {
|
||||
@objc func stopUncompletedVoiceBroadcastIfNeeded() {
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
<outletCollection property="toolbarContainerConstraints" destination="pRw-S0-6WL" id="q4S-0g-sqQ"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="QO8-nF-xys" id="aQe-20-4Pq"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="acJ-g8-R7x" id="uEo-Ez-seV"/>
|
||||
<outletCollection property="toolbarContainerConstraints" destination="ave-fu-X1D" id="xfF-6Q-MDo"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -154,7 +154,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
{
|
||||
NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage];
|
||||
[mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor,
|
||||
NSFontAttributeName: self.textDefaultFont }
|
||||
NSFontAttributeName: self.defaultFont }
|
||||
range:NSMakeRange(0, mutableTextMessage.length)];
|
||||
attributedTextMessage = mutableTextMessage;
|
||||
}
|
||||
|
@ -181,7 +181,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
|
|||
return self.textView.text;
|
||||
}
|
||||
|
||||
- (UIFont *)textDefaultFont
|
||||
- (UIFont *)defaultFont
|
||||
{
|
||||
if (self.textView.font)
|
||||
{
|
||||
|
|
|
@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
override var delegate: MXKRoomInputToolbarViewDelegate! {
|
||||
didSet {
|
||||
setupComposerIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
override var placeholder: String! {
|
||||
get {
|
||||
|
@ -85,6 +91,23 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
override var isFocused: Bool {
|
||||
viewModel.isFocused
|
||||
}
|
||||
|
||||
override var attributedTextMessage: NSAttributedString? {
|
||||
// Note: this is only interactive in plain text mode. If RTE is enabled,
|
||||
// APIs from the composer view model should be used.
|
||||
get {
|
||||
guard !self.textFormattingEnabled else { return nil }
|
||||
return self.wysiwygViewModel.textView.attributedText
|
||||
}
|
||||
set {
|
||||
guard !self.textFormattingEnabled else { return }
|
||||
self.wysiwygViewModel.textView.attributedText = newValue
|
||||
}
|
||||
}
|
||||
|
||||
override var defaultFont: UIFont {
|
||||
return UIFont.preferredFont(forTextStyle: .body)
|
||||
}
|
||||
|
||||
var isMaximised: Bool {
|
||||
wysiwygViewModel.maximised
|
||||
|
@ -120,93 +143,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
|
||||
return (delegate as? RoomInputToolbarViewDelegate) ?? nil
|
||||
}
|
||||
|
||||
private var permalinkReplacer: PermalinkReplacer? {
|
||||
return (delegate as? PermalinkReplacer)
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
viewModel = ComposerViewModel(
|
||||
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
|
||||
isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false)))
|
||||
|
||||
viewModel.callback = { [weak self] result in
|
||||
self?.handleViewModelResult(result)
|
||||
}
|
||||
wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting
|
||||
|
||||
inputAccessoryViewForKeyboard = UIView(frame: .zero)
|
||||
|
||||
let composer = Composer(
|
||||
viewModel: viewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
|
||||
sendMessageAction: { [weak self] content in
|
||||
guard let self = self else { return }
|
||||
self.sendWysiwygMessage(content: content)
|
||||
}, showSendMediaActions: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.showSendMediaActions()
|
||||
}).introspectTextView { [weak self] textView in
|
||||
guard let self = self else { return }
|
||||
textView.inputAccessoryView = self.inputAccessoryViewForKeyboard
|
||||
}
|
||||
|
||||
hostingViewController = VectorHostingController(rootView: composer)
|
||||
hostingViewController.publishHeightChanges = true
|
||||
let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height
|
||||
let subView: UIView = hostingViewController.view
|
||||
self.addSubview(subView)
|
||||
|
||||
self.translatesAutoresizingMaskIntoConstraints = false
|
||||
subView.translatesAutoresizingMaskIntoConstraints = false
|
||||
heightConstraint = subView.heightAnchor.constraint(equalToConstant: height)
|
||||
NSLayoutConstraint.activate([
|
||||
heightConstraint,
|
||||
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
|
||||
])
|
||||
|
||||
cancellables = [
|
||||
hostingViewController.heightPublisher
|
||||
.removeDuplicates()
|
||||
.sink(receiveValue: { [weak self] idealHeight in
|
||||
guard let self = self else { return }
|
||||
self.updateToolbarHeight(wysiwygHeight: idealHeight)
|
||||
}),
|
||||
// Required to update the view constraints after minimise/maximise is tapped
|
||||
wysiwygViewModel.$idealHeight
|
||||
.removeDuplicates()
|
||||
.sink { [weak hostingViewController] _ in
|
||||
hostingViewController?.view.setNeedsLayout()
|
||||
},
|
||||
|
||||
wysiwygViewModel.$maximised
|
||||
.dropFirst()
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] value in
|
||||
guard let self = self else { return }
|
||||
self.toolbarViewDelegate?.didChangeMaximisedState(value)
|
||||
self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0
|
||||
if !value {
|
||||
self.voiceMessageBottomConstraint?.constant = 2
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
update(theme: ThemeService.shared().theme)
|
||||
registerThemeServiceDidChangeThemeNotification()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillShow),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillHide),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
|
||||
setupComposerIfNeeded()
|
||||
}
|
||||
|
||||
override func customizeRendering() {
|
||||
|
@ -217,6 +162,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
override func dismissKeyboard() {
|
||||
self.viewModel.dismissKeyboard()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
self.wysiwygViewModel.textView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func dismissValidationView(_ validationView: MXKImageView!) {
|
||||
super.dismissValidationView(validationView)
|
||||
|
@ -239,8 +189,119 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
}
|
||||
wysiwygViewModel.applyLinkOperation(linkOperation)
|
||||
}
|
||||
|
||||
func mention(_ member: MXRoomMember) {
|
||||
self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId),
|
||||
name: member.displayname,
|
||||
mentionType: .user)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupComposerIfNeeded() {
|
||||
guard hostingViewController == nil,
|
||||
let toolbarViewDelegate,
|
||||
let permalinkReplacer else { return }
|
||||
|
||||
viewModel = ComposerViewModel(
|
||||
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
|
||||
isLandscapePhone: isLandscapePhone,
|
||||
bindings: ComposerBindings(focused: false)))
|
||||
|
||||
viewModel.callback = { [weak self] result in
|
||||
self?.handleViewModelResult(result)
|
||||
}
|
||||
wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting
|
||||
wysiwygViewModel.permalinkReplacer = permalinkReplacer
|
||||
|
||||
inputAccessoryViewForKeyboard = UIView(frame: .zero)
|
||||
|
||||
let composer = Composer(
|
||||
viewModel: viewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context,
|
||||
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
|
||||
sendMessageAction: { [weak self] content in
|
||||
guard let self = self else { return }
|
||||
self.sendWysiwygMessage(content: content)
|
||||
}, showSendMediaActions: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.showSendMediaActions()
|
||||
})
|
||||
.introspectTextView { [weak self] textView in
|
||||
guard let self = self else { return }
|
||||
textView.inputAccessoryView = self.inputAccessoryViewForKeyboard
|
||||
}
|
||||
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: toolbarViewDelegate.mediaManager())))
|
||||
|
||||
hostingViewController = VectorHostingController(rootView: composer)
|
||||
hostingViewController.publishHeightChanges = true
|
||||
let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height
|
||||
let subView: UIView = hostingViewController.view
|
||||
self.addSubview(subView)
|
||||
|
||||
self.translatesAutoresizingMaskIntoConstraints = false
|
||||
subView.translatesAutoresizingMaskIntoConstraints = false
|
||||
heightConstraint = subView.heightAnchor.constraint(equalToConstant: height)
|
||||
NSLayoutConstraint.activate([
|
||||
heightConstraint,
|
||||
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
|
||||
])
|
||||
|
||||
cancellables = [
|
||||
hostingViewController.heightPublisher
|
||||
.removeDuplicates()
|
||||
.sink(receiveValue: { [weak self] idealHeight in
|
||||
guard let self = self else { return }
|
||||
self.updateToolbarHeight(wysiwygHeight: idealHeight)
|
||||
}),
|
||||
// Required to update the view constraints after minimise/maximise is tapped
|
||||
wysiwygViewModel.$idealHeight
|
||||
.removeDuplicates()
|
||||
.sink { [weak hostingViewController] _ in
|
||||
hostingViewController?.view.setNeedsLayout()
|
||||
},
|
||||
|
||||
wysiwygViewModel.$maximised
|
||||
.dropFirst()
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] value in
|
||||
guard let self = self else { return }
|
||||
self.toolbarViewDelegate?.didChangeMaximisedState(value)
|
||||
self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0
|
||||
if !value {
|
||||
self.voiceMessageBottomConstraint?.constant = 2
|
||||
}
|
||||
},
|
||||
|
||||
wysiwygViewModel.$plainTextContent
|
||||
.dropFirst()
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] value in
|
||||
guard let self else { return }
|
||||
self.textMessage = value.string
|
||||
self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self)
|
||||
}
|
||||
]
|
||||
|
||||
update(theme: ThemeService.shared().theme)
|
||||
registerThemeServiceDidChangeThemeNotification()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillShow),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillHide),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
|
@ -291,6 +352,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
|
|||
setVoiceMessageToolbarIsHidden(!isEmpty)
|
||||
case let .linkTapped(linkAction):
|
||||
toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction))
|
||||
case let .suggestion(pattern):
|
||||
toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ struct UserSuggestionList: View {
|
|||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: UserSuggestionViewModel.Context
|
||||
var showBackgroundShadow: Bool = true
|
||||
|
||||
var body: some View {
|
||||
if viewModel.viewState.items.isEmpty {
|
||||
|
@ -46,25 +47,12 @@ struct UserSuggestionList: View {
|
|||
userId: "Prototype")
|
||||
.background(ViewFrameReader(frame: $prototypeListItemFrame))
|
||||
.hidden()
|
||||
BackgroundView {
|
||||
List(viewModel.viewState.items) { item in
|
||||
Button {
|
||||
viewModel.send(viewAction: .selectedItem(item))
|
||||
} label: {
|
||||
UserSuggestionListItem(
|
||||
avatar: item.avatar,
|
||||
displayName: item.displayName,
|
||||
userId: item.id
|
||||
)
|
||||
.padding(.bottom, Constants.listItemPadding)
|
||||
.padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding)
|
||||
}
|
||||
if showBackgroundShadow {
|
||||
BackgroundView {
|
||||
list()
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.frame(height: min(Constants.maxHeight,
|
||||
min(contentHeightForRowCount(Constants.maxVisibleRows),
|
||||
contentHeightForRowCount(viewModel.viewState.items.count))))
|
||||
.id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues.
|
||||
} else {
|
||||
list()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +61,27 @@ struct UserSuggestionList: View {
|
|||
private func contentHeightForRowCount(_ count: Int) -> CGFloat {
|
||||
(prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding
|
||||
}
|
||||
|
||||
private func list() -> some View {
|
||||
List(viewModel.viewState.items) { item in
|
||||
Button {
|
||||
viewModel.send(viewAction: .selectedItem(item))
|
||||
} label: {
|
||||
UserSuggestionListItem(
|
||||
avatar: item.avatar,
|
||||
displayName: item.displayName,
|
||||
userId: item.id
|
||||
)
|
||||
.padding(.bottom, Constants.listItemPadding)
|
||||
.padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding)
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.frame(height: min(Constants.maxHeight,
|
||||
min(contentHeightForRowCount(Constants.maxVisibleRows),
|
||||
contentHeightForRowCount(viewModel.viewState.items.count))))
|
||||
.id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues.
|
||||
}
|
||||
}
|
||||
|
||||
private struct BackgroundView<Content: View>: View {
|
||||
|
|
|
@ -29,12 +29,14 @@ private enum Inputs {
|
|||
static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org")
|
||||
static let alicePermalink = "https://matrix.to/#/@alice:matrix.org"
|
||||
static let mentionToAlice = NSAttributedString(string: aliceDisplayname, attributes: [.link: URL(string: alicePermalink)!])
|
||||
static let markdownLinkToAlice = "[Alice](\(alicePermalink))"
|
||||
static let markdownLinkToAlice = "[\(aliceDisplayname)](\(alicePermalink))"
|
||||
|
||||
static let bobUserId = "@bob:matrix.org"
|
||||
static let bobDisplayname = "Bob"
|
||||
static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ"
|
||||
static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId)
|
||||
static let bobPermalink = "https://matrix.to/#/@bob:matrix.org"
|
||||
static let markdownLinkToBob = "[\(bobDisplayname)](\(bobPermalink))"
|
||||
|
||||
static let anotherUserId = "@another.user:matrix.org"
|
||||
static let anotherUserPermalink = "https://matrix.to/#/@another.user:matrix.org"
|
||||
|
@ -310,7 +312,7 @@ class PillsFormatterTests: XCTestCase {
|
|||
case .room(let userId):
|
||||
XCTAssertEqual(userId, Inputs.roomId)
|
||||
switch pillTextAttachmentData.items.first {
|
||||
case .asset(let assetName, let parameters):
|
||||
case .asset(let assetName, _):
|
||||
XCTAssertEqual(assetName, "link_icon")
|
||||
default:
|
||||
XCTFail("First pill item should be the asset")
|
||||
|
@ -436,7 +438,7 @@ class PillsFormatterTests: XCTestCase {
|
|||
XCTAssertEqual(roomId, Inputs.anotherRoomId)
|
||||
XCTAssertEqual(messageId, Inputs.messageEventId)
|
||||
switch pillTextAttachmentData.items.first {
|
||||
case .asset(let name, let parameters):
|
||||
case .asset(let name, _):
|
||||
XCTAssertEqual(name, "link_icon")
|
||||
default:
|
||||
XCTFail("First pill item should be the asset")
|
||||
|
@ -445,6 +447,79 @@ class PillsFormatterTests: XCTestCase {
|
|||
XCTFail("Pill should be of type .message")
|
||||
}
|
||||
}
|
||||
|
||||
func testInsertPillInMarkdownString() {
|
||||
let message = "Hello \(Inputs.markdownLinkToBob)"
|
||||
let messageWithPills = insertPillsInMarkdownString(message)
|
||||
XCTAssertTrue(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment)
|
||||
let pillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment
|
||||
XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.bobDisplayname)
|
||||
}
|
||||
|
||||
func testInsertMultiplePillsInMarkdownString() {
|
||||
let message = "Hello \(Inputs.markdownLinkToBob) and \(Inputs.markdownLinkToAlice)"
|
||||
let messageWithPills = insertPillsInMarkdownString(message)
|
||||
let bobPillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment
|
||||
XCTAssertEqual(bobPillTextAttachment?.data?.displayText, Inputs.bobDisplayname)
|
||||
|
||||
let alicePillTextAttachment = messageWithPills.attribute(.attachment, at: 12, effectiveRange: nil) as? PillTextAttachment
|
||||
XCTAssertEqual(alicePillTextAttachment?.data?.displayText, Inputs.aliceDisplayname)
|
||||
// No self highlight
|
||||
XCTAssert(alicePillTextAttachment?.data?.isHighlighted == false)
|
||||
}
|
||||
|
||||
func testMarkdownLinkToUnknownUserIsNotPillified() {
|
||||
let message = "Hello [Unknown user](https://matrix.to/#/@unknown:matrix.org)"
|
||||
let messageWithPills = insertPillsInMarkdownString(message)
|
||||
XCTAssertFalse(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment)
|
||||
}
|
||||
|
||||
func testMarkdownSingleLinkDetection() {
|
||||
let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice)")
|
||||
let expected = [
|
||||
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!,
|
||||
label: Inputs.aliceDisplayname,
|
||||
range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count))
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
PillsFormatter.markdownLinks(in: message),
|
||||
expected
|
||||
)
|
||||
}
|
||||
|
||||
func testMarkdownMultipleLinksDetection() {
|
||||
let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice) and \(Inputs.markdownLinkToBob)")
|
||||
let expected = [
|
||||
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!,
|
||||
label: Inputs.aliceDisplayname,
|
||||
range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)),
|
||||
PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.bobPermalink)!,
|
||||
label: Inputs.bobDisplayname,
|
||||
range: NSRange(location: 6 + Inputs.markdownLinkToAlice.count + 5,
|
||||
length: Inputs.markdownLinkToBob.count))
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
PillsFormatter.markdownLinks(in: message),
|
||||
expected
|
||||
)
|
||||
}
|
||||
|
||||
func testBrokenMarkdownLinkIsNotDetected() {
|
||||
let brokenMarkdownMessages = [
|
||||
NSAttributedString(string: "Hello [Alice](https://matrix.to/#/@alice:matrix.org"),
|
||||
NSAttributedString(string: "Hello [Alice]https://matrix.to/#/@alice:matrix.org)"),
|
||||
NSAttributedString(string: "Hello [Alice(https://matrix.to/#/@alice:matrix.org)"),
|
||||
NSAttributedString(string: "Hello Alice](https://matrix.to/#/@alice:matrix.org)"),
|
||||
NSAttributedString(string: "Hello [Alice]](https://matrix.to/#/@alice:matrix.org)"),
|
||||
NSAttributedString(string: "Hello (https://matrix.to/#/@alice:matrix.org)"),
|
||||
]
|
||||
|
||||
for message in brokenMarkdownMessages {
|
||||
XCTAssertTrue(PillsFormatter.markdownLinks(in: message).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
|
@ -604,6 +679,15 @@ private extension PillsFormatterTests {
|
|||
return messageWithPills
|
||||
}
|
||||
|
||||
private func insertPillsInMarkdownString(_ markdownString: String) -> NSAttributedString {
|
||||
let message = NSAttributedString(string: markdownString)
|
||||
let session = FakeMXSession(myUserId: Inputs.aliceUserId)
|
||||
return PillsFormatter.insertPills(in: message,
|
||||
withSession: session,
|
||||
eventFormatter: EventFormatter(matrixSession: session),
|
||||
roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()),
|
||||
font: UIFont.systemFont(ofSize: 15.0))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock objects
|
||||
|
|
1
changelog.d/7442.change
Normal file
1
changelog.d/7442.change
Normal file
|
@ -0,0 +1 @@
|
|||
Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support.
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue