diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h index 2b2ba8945..410f2eb14 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h @@ -170,6 +170,11 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; */ @property (nonatomic) NSString *partialTextMessage; +/** + The current attributed text message partially typed in text input (use nil to reset it). + */ +@property (nonatomic) NSAttributedString *attributedPartialTextMessage; + /** The current thread id for the data source. If provided, data source displays the specified thread, otherwise the whole room messages. */ @@ -471,6 +476,8 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; success:(void (^)(NSString *))success failure:(void (^)(NSError *))failure; +- (void)updateEventWithReplaceEvent:(MXEvent*)replaceEvent; + /** Indicates if replying to the provided event is supported. Only event of type 'MXEventTypeRoomMessage' are supported for the moment, and for certain msgtype. @@ -735,6 +742,25 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; roomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction; +/** + Queue an event in order to process its display later. + + @param event the event to process. + @param roomState the state of the room when the event fired. + @param direction the order of the events in the arrays + */ +- (void)queueEventForProcessing:(MXEvent*)event + withRoomState:(MXRoomState*)roomState + direction:(MXTimelineDirection)direction; + +/** + Start processing pending events. + + @param onComplete a block called (on the main thread) when the processing has been done. Can be nil. + Note this block returns the number of added cells in first and last positions. + */ +- (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete; + #pragma mark - Bubble collapsing /** diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index 12e2deffa..c86a54605 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -31,7 +31,6 @@ #import "MXKAppSettings.h" -#import "MXKSendReplyEventStringLocalizer.h" #import "MXKSlashCommands.h" #import "GeneratedInterface-Swift.h" @@ -991,6 +990,16 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { _room.partialTextMessage = partialTextMessage; } +- (NSAttributedString *)attributedPartialTextMessage +{ + return _room.attributedPartialTextMessage; +} + +- (void)setAttributedPartialTextMessage:(NSAttributedString *)attributedPartialTextMessage +{ + _room.attributedPartialTextMessage = attributedPartialTextMessage; +} + - (void)refreshEventListeners:(NSArray *)liveEventTypesFilterForMessages { // Remove the existing listeners @@ -2982,13 +2991,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { return processingQueue; } -/** - Queue an event in order to process its display later. - - @param event the event to process. - @param roomState the state of the room when the event fired. - @param direction the order of the events in the arrays - */ - (void)queueEventForProcessing:(MXEvent*)event withRoomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction { if (event.isLocalEvent) @@ -3166,12 +3168,6 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { return isHighlighted; } -/** - Start processing pending events. - - @param onComplete a block called (on the main thread) when the processing has been done. Can be nil. - Note this block returns the number of added cells in first and last positions. - */ - (void)processQueuedEvents:(void (^)(NSUInteger addedHistoryCellNb, NSUInteger addedLiveCellNb))onComplete { MXWeakify(self); diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h deleted file mode 100644 index 6d0d4b842..000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.h +++ /dev/null @@ -1,25 +0,0 @@ -/* - Copyright 2018 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 -#import - -/** - A `MXKSendReplyEventStringLocalizer` instance represents string localizations used when send reply event to a message in a room. - */ -@interface MXKSendReplyEventStringLocalizer : NSObject - -@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m deleted file mode 100644 index 206d8d548..000000000 --- a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.m +++ /dev/null @@ -1,58 +0,0 @@ -/* - Copyright 2018 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 "MXKSendReplyEventStringLocalizer.h" -#import "MXKSwiftHeader.h" - -@implementation MXKSendReplyEventStringLocalizer - -- (NSString *)senderSentAnImage -{ - return [VectorL10n messageReplyToSenderSentAnImage]; -} - -- (NSString *)senderSentAVideo -{ - return [VectorL10n messageReplyToSenderSentAVideo]; -} - -- (NSString *)senderSentAnAudioFile -{ - return [VectorL10n messageReplyToSenderSentAnAudioFile]; -} - -- (NSString *)senderSentAVoiceMessage -{ - return [VectorL10n messageReplyToSenderSentAVoiceMessage]; -} - -- (NSString *)senderSentAFile -{ - return [VectorL10n messageReplyToSenderSentAFile]; -} - -- (NSString *)senderSentTheirLocation -{ - return [VectorL10n messageReplyToSenderSentTheirLocation]; -} - -- (NSString *)messageToReplyToPrefix -{ - return [VectorL10n messageReplyToMessageToReplyToPrefix]; -} - -@end diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.swift b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.swift new file mode 100644 index 000000000..3df501245 --- /dev/null +++ b/Riot/Modules/MatrixKit/Models/Room/MXKSendReplyEventStringLocalizer.swift @@ -0,0 +1,47 @@ +// +// Copyright 2022 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 Foundation + +class MXKSendReplyEventStringLocalizer: NSObject, MXSendReplyEventStringLocalizerProtocol { + func senderSentAnImage() -> String { + return VectorL10n.messageReplyToSenderSentAnImage + } + + func senderSentAVideo() -> String { + return VectorL10n.messageReplyToSenderSentAVideo + } + + func senderSentAnAudioFile() -> String { + return VectorL10n.messageReplyToSenderSentAnAudioFile + } + + func senderSentAVoiceMessage() -> String { + return VectorL10n.messageReplyToSenderSentAVoiceMessage + } + + func senderSentAFile() -> String { + return VectorL10n.messageReplyToSenderSentAFile + } + + func senderSentTheirLocation() -> String { + return VectorL10n.messageReplyToSenderSentTheirLocation + } + + func messageToReplyToPrefix() -> String { + return VectorL10n.messageReplyToMessageToReplyToPrefix + } +} diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index 3572b95e5..870b7ff09 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -269,6 +269,9 @@ typedef enum : NSUInteger */ - (IBAction)onTouchUpInside:(UIButton*)button; + +- (void)sendCurrentMessage; + /** Handle image attachment Save the image in user's photos library when 'isPhotoLibraryAsset' flag is NO and auto saving is enabled. diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 47e9e6909..e1c872d1c 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -277,22 +277,27 @@ } else if (button == self.rightInputToolbarButton && self.textMessage.length) { - // This forces an autocorrect event to happen when "Send" is pressed, which is necessary to accept a pending correction on send - self.textMessage = [NSString stringWithFormat:@"%@ ", self.textMessage]; - self.textMessage = [self.textMessage substringToIndex:[self.textMessage length]-1]; + [self sendCurrentMessage]; + } +} - NSString *message = self.textMessage; - - // Reset message, disable view animation during the update to prevent placeholder distorsion. - [UIView setAnimationsEnabled:NO]; - self.textMessage = nil; - [UIView setAnimationsEnabled:YES]; - - // Send button has been pressed - if (message.length && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendTextMessage:)]) - { - [self.delegate roomInputToolbarView:self sendTextMessage:message]; - } +- (void)sendCurrentMessage +{ + // This forces an autocorrect event to happen when "Send" is pressed, which is necessary to accept a pending correction on send + self.textMessage = [NSString stringWithFormat:@"%@ ", self.textMessage]; + self.textMessage = [self.textMessage substringToIndex:[self.textMessage length]-1]; + + NSString *message = self.textMessage; + + // Reset message, disable view animation during the update to prevent placeholder distorsion. + [UIView setAnimationsEnabled:NO]; + self.textMessage = nil; + [UIView setAnimationsEnabled:YES]; + + // Send button has been pressed + if (message.length && [self.delegate respondsToSelector:@selector(roomInputToolbarView:sendTextMessage:)]) + { + [self.delegate roomInputToolbarView:self sendTextMessage:message]; } } diff --git a/Riot/Modules/Pills/PillTextAttachment.swift b/Riot/Modules/Pills/PillTextAttachment.swift index fd67de9bd..27d9b79c0 100644 --- a/Riot/Modules/Pills/PillTextAttachment.swift +++ b/Riot/Modules/Pills/PillTextAttachment.swift @@ -23,11 +23,45 @@ import MatrixSDK var isCurrentUser: Bool = false var alpha: CGFloat = 1.0 - convenience init(withRoomMember roomMember: MXRoomMember, isCurrentUser: Bool) { - self.init(data: nil, ofType: "im.vector.app.pills") + private enum Constants { + static let roomMemberKey: String = "roomMember" + static let isCurrentUserKey: String = "isCurrentUser" + static let alphaKey: String = "alpha" + } + + override init(data contentData: Data?, ofType uti: String?) { + super.init(data: contentData, ofType: uti) + } + + init(withRoomMember roomMember: MXRoomMember, isCurrentUser: Bool) { + super.init(data: nil, ofType: "im.vector.app.pills") self.roomMember = roomMember self.isCurrentUser = isCurrentUser let pillSize = PillAttachmentView.size(forRoomMember: roomMember) self.bounds = CGRect(origin: CGPoint(x: 0.0, y: -6.5), size: pillSize) } + + required init?(coder: NSCoder) { + guard let roomMember = coder.decodeObject(of: MXRoomMember.self, forKey: Constants.roomMemberKey) else { + return nil + } + + super.init(coder: coder) + self.fileType = "im.vector.app.pills" + + self.roomMember = roomMember + self.isCurrentUser = coder.decodeBool(forKey: Constants.isCurrentUserKey) + self.alpha = CGFloat(coder.decodeFloat(forKey: Constants.alphaKey)) + + let pillSize = PillAttachmentView.size(forRoomMember: roomMember) + self.bounds = CGRect(origin: CGPoint(x: 0.0, y: -6.5), size: pillSize) + } + + override func encode(with coder: NSCoder) { + super.encode(with: coder) + + coder.encode(roomMember, forKey: Constants.roomMemberKey) + coder.encode(isCurrentUser, forKey: Constants.isCurrentUserKey) + coder.encode(Float(alpha), forKey: Constants.alphaKey) + } } diff --git a/Riot/Modules/Pills/StringPillsUtils.swift b/Riot/Modules/Pills/StringPillsUtils.swift index 848d9a33b..17cc37776 100644 --- a/Riot/Modules/Pills/StringPillsUtils.swift +++ b/Riot/Modules/Pills/StringPillsUtils.swift @@ -48,6 +48,25 @@ class StringPillsUtils: NSObject { return newAttr } + /// Creates a string with all pills of given attributed string replaced by common display names. + /// + /// - Parameter attributedString: attributed string with pills + /// - Returns: string with display names + static func stringByReplacingPills(in attributedString: NSAttributedString, asMarkdown: Bool = false) -> String { + let newAttr = NSMutableAttributedString(attributedString: attributedString) + let totalRange = NSRange(location: 0, length: newAttr.length) + + newAttr.vc_enumerateAttribute(.attachment, in: totalRange) { (attachment: PillTextAttachment, range: NSRange, _) in + if let displayname = attachment.roomMember?.displayname, + let url = newAttr.attribute(.link, at: range.location, effectiveRange: nil) as? URL { + let pillString = asMarkdown ? "[\(displayname)](\(url.absoluteString))" : "\(displayname)" + newAttr.replaceCharacters(in: range, with: pillString) + } + } + + return newAttr.string + } + /// Creates an attributed string containing a pill for given room member. /// /// - Parameters: diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.swift b/Riot/Modules/Room/DataSources/RoomDataSource.swift new file mode 100644 index 000000000..d6b42f786 --- /dev/null +++ b/Riot/Modules/Room/DataSources/RoomDataSource.swift @@ -0,0 +1,131 @@ +// +// Copyright 2022 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 Foundation + +extension RoomDataSource { + func sendAttributedTextMessage(_ attributedText: NSAttributedString, + completion: @escaping (MXResponse) -> Void) { + var localEcho: MXEvent? + + // TODO: emote + let sanitized = sanitizedAttributedMessageText(attributedText) + let rawText: String + let html: String? = htmlMessageFromSanitizedAttributedText(sanitized) + if #available(iOS 15.0, *) { + rawText = StringPillsUtils.stringByReplacingPills(in: sanitized) + } else { + rawText = sanitized.string + } + + room.sendTextMessage(rawText, + formattedText: html, + threadId: self.threadId, + localEcho: &localEcho, + completion: completion) + + if localEcho != nil { + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + } + } + + func sendReply(to eventToReply: MXEvent, + withAttributedTextMessage attributedText: NSAttributedString, + completion: @escaping (MXResponse) -> Void) { + var localEcho: MXEvent? + + let sanitized = sanitizedAttributedMessageText(attributedText) + let rawText: String + let html: String? = htmlMessageFromSanitizedAttributedText(sanitized) + if #available(iOS 15.0, *) { + rawText = StringPillsUtils.stringByReplacingPills(in: sanitized) + } else { + rawText = sanitized.string + } + + let stringLocalizer: MXSendReplyEventStringLocalizerProtocol = MXKSendReplyEventStringLocalizer() + + room.sendReply(to: eventToReply, + textMessage: rawText, + formattedTextMessage: html, + stringLocalizer: stringLocalizer, + threadId: self.threadId, + localEcho: &localEcho, + completion: completion) + + if localEcho != nil { + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + } + } + + func replaceAttributedTextMessage(for event: MXEvent, + withAttributedTextMessage attributedText: NSAttributedString, + success: @escaping ((String?) -> Void), + failure: @escaping ((Error?) -> Void)) { + let sanitized = sanitizedAttributedMessageText(attributedText) + let rawText: String + let html: String? = htmlMessageFromSanitizedAttributedText(sanitized) + if #available(iOS 15.0, *) { + rawText = StringPillsUtils.stringByReplacingPills(in: sanitized) + } else { + rawText = sanitized.string + } + + let eventBody = event.content[kMXMessageBodyKey] as? String + let eventFormattedBody = event.content["formatted_body"] as? String + + if rawText != eventBody && (eventFormattedBody == nil || html != eventFormattedBody) { + self.mxSession.aggregations.replaceTextMessageEvent( + event, + withTextMessage: rawText, + formattedText: html, + localEcho: { localEcho in + // Apply the local echo to the timeline + self.updateEvent(withReplace: localEcho) + + // Integrate the replace local event into the timeline like when sending a message + // This also allows to manage read receipt on this replace event + self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards) + self.processQueuedEvents(nil) + }, + success: success, + failure: failure) + } else { + failure(nil) + } + } + + func sanitizedAttributedMessageText(_ attributedString: NSAttributedString) -> NSAttributedString { + let newAttr = NSMutableAttributedString(attributedString: attributedString) + newAttr.mutableString.replaceOccurrences(of: String(format: "%C", 0x00000000), with: "", range: .init(location: 0, length: newAttr.length)) + return newAttr + } + + func htmlMessageFromSanitizedAttributedText(_ sanitizedText: NSAttributedString) -> String? { + let rawText: String + if #available(iOS 15.0, *) { + rawText = StringPillsUtils.stringByReplacingPills(in: sanitizedText, asMarkdown: true) + } else { + rawText = sanitizedText.string + } + + let html = eventFormatter.htmlString(fromMarkdownString: rawText) + + return html == sanitizedText.string ? nil : html + } +} diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 6a76b76fc..666fa8afb 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -100,6 +100,13 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; /// Displayed live location sharing banner if any @property (nonatomic, weak) LiveLocationSharingBannerView *liveLocationSharingBannerView; +// The customized room data source for Vector +@property (nonatomic, nullable) RoomDataSource *customizedRoomDataSource; + +- (void)setupRoomDataSourceToResolveEvent: (void (^)(MXKRoomDataSource *roomDataSource))onComplete; + +- (void)cancelEventSelection; + /** Display the preview of a room that is unknown for the user. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index b941df182..14d69995e 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -103,9 +103,6 @@ static CGSize kThreadListBarButtonItemImageSize; // The preview header __weak PreviewRoomTitleView *previewHeader; - // The customized room data source for Vector - RoomDataSource *customizedRoomDataSource; - // The user taps on a user id contained in a message MXKContact *selectedContact; @@ -561,10 +558,10 @@ static CGSize kThreadListBarButtonItemImageSize; [self removeTypingNotificationsListener]; - if (customizedRoomDataSource) + if (self.customizedRoomDataSource) { // Cancel potential selected event (to leave edition mode) - if (customizedRoomDataSource.selectedEventId) + if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } @@ -662,6 +659,15 @@ static CGSize kThreadListBarButtonItemImageSize; { [self showThreadsNotice]; } + + if (self.saveProgressTextInput && self.roomDataSource) + { + // Retrieve the potential message partially typed during last room display. + // Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early) + RoomInputToolbarView *inputToolbar = (RoomInputToolbarView *)self.inputToolbarView; + + inputToolbar.attributedTextMessage = self.roomDataSource.attributedPartialTextMessage; + } } - (void)viewDidDisappear:(BOOL)animated @@ -1020,7 +1026,7 @@ static CGSize kThreadListBarButtonItemImageSize; [super displayRoom:dataSource]; - customizedRoomDataSource = nil; + self.customizedRoomDataSource = nil; if (self.roomDataSource) { @@ -1031,7 +1037,7 @@ static CGSize kThreadListBarButtonItemImageSize; // Store ref on customized room data source if ([dataSource isKindOfClass:RoomDataSource.class]) { - customizedRoomDataSource = (RoomDataSource*)dataSource; + self.customizedRoomDataSource = (RoomDataSource*)dataSource; } // Set room title view @@ -1312,7 +1318,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)sendTextMessage:(NSString*)msgTxt { // The event modified is always fetch from the actual data source - MXEvent *eventModified = [self.roomDataSource eventWithEventId:customizedRoomDataSource.selectedEventId]; + MXEvent *eventModified = [self.roomDataSource eventWithEventId:self.customizedRoomDataSource.selectedEventId]; // In the case the event is a reply or and edit, and it's done on a non-live timeline // we have to fetch live timeline in order to display the event properly @@ -1341,7 +1347,7 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } - if (self->customizedRoomDataSource.selectedEventId) + if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } @@ -1413,10 +1419,10 @@ static CGSize kThreadListBarButtonItemImageSize; currentAlert = nil; } - if (customizedRoomDataSource) + if (self.customizedRoomDataSource) { - customizedRoomDataSource.selectedEventId = nil; - customizedRoomDataSource = nil; + self.customizedRoomDataSource.selectedEventId = nil; + self.customizedRoomDataSource = nil; } [self removeTypingNotificationsListener]; @@ -1524,7 +1530,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (BOOL)shouldShowLiveLocationSharingBannerView { - return customizedRoomDataSource.isCurrentUserSharingIsLocation; + return self.customizedRoomDataSource.isCurrentUserSharingIsLocation; } #pragma mark - Internals @@ -1635,7 +1641,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXCall *callInRoom = [self.roomDataSource.mxSession.callManager callInRoom:self.roomDataSource.roomId]; return (callInRoom && callInRoom.state != MXCallStateEnded) - || customizedRoomDataSource.jitsiWidget; + || self.customizedRoomDataSource.jitsiWidget; } /** @@ -1973,7 +1979,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)handleLongPressFromCell:(id)cell withTappedEvent:(MXEvent*)event { - if (event && !customizedRoomDataSource.selectedEventId) + if (event && !self.customizedRoomDataSource.selectedEventId) { [self showContextualMenuForEvent:event fromSingleTapGesture:NO cell:cell animated:YES]; } @@ -2749,7 +2755,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (Class)cellViewClassForCellData:(MXKCellData*)cellData { - RoomTimelineCellIdentifier cellIdentifier = [self cellIdentifierForCellData:cellData andRoomDataSource:customizedRoomDataSource]; + RoomTimelineCellIdentifier cellIdentifier = [self cellIdentifierForCellData:cellData andRoomDataSource:self.customizedRoomDataSource]; RoomTimelineConfiguration *timelineConfiguration = [RoomTimelineConfiguration shared]; @@ -3131,7 +3137,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)dataSource:(MXKDataSource *)dataSource didRecognizeAction:(NSString *)actionIdentifier inCell:(id)cell userInfo:(NSDictionary *)userInfo { // Handle here user actions on bubbles for Vector app - if (customizedRoomDataSource) + if (self.customizedRoomDataSource) { id bubbleData; @@ -3162,7 +3168,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXEvent *tappedEvent = userInfo[kMXKRoomBubbleCellEventKey]; // Check whether a selection already exist or not - if (customizedRoomDataSource.selectedEventId) + if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } @@ -3296,7 +3302,7 @@ static CGSize kThreadListBarButtonItemImageSize; // We consider this tap like a selection. // Check whether a selection already exist or not - if (customizedRoomDataSource.selectedEventId) + if (self.customizedRoomDataSource.selectedEventId) { [self cancelEventSelection]; } @@ -3330,7 +3336,7 @@ static CGSize kThreadListBarButtonItemImageSize; else if ([actionIdentifier isEqualToString:kRoomMembershipExpandedBubbleCellTapOnCollapseButton]) { // Reset the selection before collapsing - customizedRoomDataSource.selectedEventId = nil; + self.customizedRoomDataSource.selectedEventId = nil; [self.roomDataSource collapseRoomBubble:((MXKRoomBubbleTableViewCell*)cell).bubbleData collapsed:YES]; } @@ -3397,7 +3403,7 @@ static CGSize kThreadListBarButtonItemImageSize; if (granted) { // Present the Jitsi view controller - Widget *jitsiWidget = [self->customizedRoomDataSource jitsiWidget]; + Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget) { [self showJitsiCallWithWidget:jitsiWidget]; @@ -3411,7 +3417,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent - inMatrixSession:customizedRoomDataSource.mxSession]; + inMatrixSession:self.customizedRoomDataSource.mxSession]; [[JitsiService shared] resetDeclineForWidgetWithId:widget.widgetId]; } else if ([actionIdentifier isEqualToString:RoomGroupCallStatusCell.leaveAction]) @@ -3423,7 +3429,7 @@ static CGSize kThreadListBarButtonItemImageSize; { MXEvent *widgetEvent = userInfo[kMXKRoomBubbleCellEventKey]; Widget *widget = [[Widget alloc] initWithWidgetEvent:widgetEvent - inMatrixSession:customizedRoomDataSource.mxSession]; + inMatrixSession:self.customizedRoomDataSource.mxSession]; [[JitsiService shared] declineWidgetWithId:widget.widgetId]; [self reloadBubblesTable:YES]; } @@ -4327,8 +4333,8 @@ static CGSize kThreadListBarButtonItemImageSize; { [self setInputToolBarSendMode:inputToolBarSendMode forEventWithId:eventId]; - customizedRoomDataSource.showBubbleDateTimeOnSelection = showTimestamp; - customizedRoomDataSource.selectedEventId = eventId; + self.customizedRoomDataSource.showBubbleDateTimeOnSelection = showTimestamp; + self.customizedRoomDataSource.selectedEventId = eventId; // Force table refresh [self dataSource:self.roomDataSource didCellChange:nil]; @@ -4344,9 +4350,9 @@ static CGSize kThreadListBarButtonItemImageSize; currentAlert = nil; } - customizedRoomDataSource.showBubbleDateTimeOnSelection = YES; - customizedRoomDataSource.selectedEventId = nil; - customizedRoomDataSource.highlightedEventId = nil; + self.customizedRoomDataSource.showBubbleDateTimeOnSelection = YES; + self.customizedRoomDataSource.selectedEventId = nil; + self.customizedRoomDataSource.highlightedEventId = nil; [self restoreTextMessageBeforeEditing]; @@ -4558,7 +4564,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)placeCallWithVideo2:(BOOL)video { - Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; + Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget) { // If there is already a Jitsi call, join it @@ -4647,9 +4653,18 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView isTyping:(BOOL)typing { [super roomInputToolbarView:toolbarView isTyping:typing]; - + + // TODO: Improve so we don't save partial message twice. + RoomInputToolbarView *inputToolbar = (RoomInputToolbarView *)toolbarView; + + if (self.saveProgressTextInput && self.roomDataSource && inputToolbar) + { + // Store the potential message partially typed in text input + self.roomDataSource.attributedPartialTextMessage = inputToolbar.attributedTextMessage; + } + // Cancel potential selected event (to leave edition mode) - NSString *selectedEventId = customizedRoomDataSource.selectedEventId; + NSString *selectedEventId = self.customizedRoomDataSource.selectedEventId; if (typing && selectedEventId && ![self.roomDataSource canReplyToEventWithId:selectedEventId]) { [self cancelEventSelection]; @@ -4704,6 +4719,11 @@ static CGSize kThreadListBarButtonItemImageSize; } } +- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage +{ + [self sendAttributedTextMessage:attributedTextMessage]; +} + #pragma mark - MXKRoomMemberDetailsViewControllerDelegate - (void)roomMemberDetailsViewController:(MXKRoomMemberDetailsViewController *)roomMemberDetailsViewController startChatWithMemberId:(NSString *)matrixId completion:(void (^)(void))completion @@ -5259,7 +5279,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXStrongifyAndReturnIfNil(self); MXCall *call = notif.object; - if ([call.room.roomId isEqualToString:self->customizedRoomDataSource.roomId]) + if ([call.room.roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; [self refreshRoomInputToolbar]; @@ -5270,7 +5290,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXStrongifyAndReturnIfNil(self); NSString *roomId = notif.object; - if ([roomId isEqualToString:self->customizedRoomDataSource.roomId]) + if ([roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; } @@ -5280,7 +5300,7 @@ static CGSize kThreadListBarButtonItemImageSize; MXStrongifyAndReturnIfNil(self); NSString *roomId = notif.object; - if ([roomId isEqualToString:self->customizedRoomDataSource.roomId]) + if ([roomId isEqualToString:self.customizedRoomDataSource.roomId]) { [self refreshActivitiesViewDisplay]; [self refreshRoomInputToolbar]; @@ -5340,7 +5360,7 @@ static CGSize kThreadListBarButtonItemImageSize; Widget *widget = notif.object; if (widget.mxSession == self.roomDataSource.mxSession - && [widget.roomId isEqualToString:self->customizedRoomDataSource.roomId]) + && [widget.roomId isEqualToString:self.customizedRoomDataSource.roomId]) { // Call button update [self refreshRoomTitle]; @@ -5415,16 +5435,16 @@ static CGSize kThreadListBarButtonItemImageSize; self.activitiesViewExpanded = YES; [roomActivitiesView displayNetworkErrorNotification:[VectorL10n roomOfflineNotification]]; } - else if (customizedRoomDataSource.roomState.isObsolete) + else if (self.customizedRoomDataSource.roomState.isObsolete) { self.activitiesViewExpanded = YES; MXWeakify(self); [roomActivitiesView displayRoomReplacementWithRoomLinkTappedHandler:^{ MXStrongifyAndReturnIfNil(self); - MXEvent *stoneTombEvent = [self->customizedRoomDataSource.roomState stateEventsWithType:kMXEventTypeStringRoomTombStone].lastObject; + MXEvent *stoneTombEvent = [self.customizedRoomDataSource.roomState stateEventsWithType:kMXEventTypeStringRoomTombStone].lastObject; - NSString *replacementRoomId = self->customizedRoomDataSource.roomState.tombStoneContent.replacementRoomId; + NSString *replacementRoomId = self.customizedRoomDataSource.roomState.tombStoneContent.replacementRoomId; if ([self.roomDataSource.mxSession roomWithRoomId:replacementRoomId]) { // Open the room if it is already joined @@ -5433,7 +5453,7 @@ static CGSize kThreadListBarButtonItemImageSize; else { // Else auto join it via the server that sent the event - MXLogDebug(@"[RoomVC] Auto join an upgraded room: %@ -> %@. Sender: %@", self->customizedRoomDataSource.roomState.roomId, + MXLogDebug(@"[RoomVC] Auto join an upgraded room: %@ -> %@. Sender: %@", self.customizedRoomDataSource.roomState.roomId, replacementRoomId, stoneTombEvent.sender); NSString *viaSenderServer = [MXTools serverNameInMatrixIdentifier:stoneTombEvent.sender]; @@ -5822,10 +5842,10 @@ static CGSize kThreadListBarButtonItemImageSize; MXEvent *event = notif.object; NSString *previousId = notif.userInfo[kMXEventIdentifierKey]; - if ([customizedRoomDataSource.selectedEventId isEqualToString:previousId]) + if ([self.customizedRoomDataSource.selectedEventId isEqualToString:previousId]) { MXLogDebug(@"[RoomVC] eventDidChangeIdentifier: Update selectedEventId"); - customizedRoomDataSource.selectedEventId = event.eventId; + self.customizedRoomDataSource.selectedEventId = event.eventId; } } @@ -6035,7 +6055,7 @@ static CGSize kThreadListBarButtonItemImageSize; if (self.roomDataSource.isLive && !self.roomDataSource.isPeeking) { - Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; + Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; if (jitsiWidget && self.canEditJitsiWidget) { @@ -6899,7 +6919,7 @@ static CGSize kThreadListBarButtonItemImageSize; return; } - self->customizedRoomDataSource.highlightedEventId = eventId; + self.customizedRoomDataSource.highlightedEventId = eventId; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) @@ -6925,19 +6945,19 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)cancelEventHighlight { // if data source is highlighting an event, dismiss the highlight when user dragges the table view - if (customizedRoomDataSource.highlightedEventId) + if (self.customizedRoomDataSource.highlightedEventId) { - NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:customizedRoomDataSource.highlightedEventId]; + NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:self.customizedRoomDataSource.highlightedEventId]; if (row == NSNotFound) { - customizedRoomDataSource.highlightedEventId = nil; + self.customizedRoomDataSource.highlightedEventId = nil; return; } NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) { - customizedRoomDataSource.highlightedEventId = nil; + self.customizedRoomDataSource.highlightedEventId = nil; [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; } @@ -7338,7 +7358,7 @@ static CGSize kThreadListBarButtonItemImageSize; - (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view { view.delegate = nil; - Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; + Widget *jitsiWidget = [self.customizedRoomDataSource jitsiWidget]; [self startActivityIndicator]; diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift new file mode 100644 index 000000000..3a071ce04 --- /dev/null +++ b/Riot/Modules/Room/RoomViewController.swift @@ -0,0 +1,101 @@ +// +// Copyright 2022 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 + +extension RoomViewController { + open override func mention(_ roomMember: MXRoomMember) { + guard #available(iOS 15.0, *), + let inputToolbar = inputToolbarView as? RoomInputToolbarView, + let permalink = URL(string: MXTools.permalinkToUser(withUserId: roomMember.userId)) else { + super.mention(roomMember) + return + } + + let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage) + + if inputToolbar.attributedTextMessage.length > 0 { + newAttributedString.append(StringPillsUtils.mentionPill(withRoomMember: roomMember, + andUrl: permalink, + isCurrentUser: false)) + let empty = NSAttributedString(string: " ", + attributes: [.font: inputToolbar.textDefaultFont ?? ThemeService.shared().theme.fonts.body]) + newAttributedString.append(empty) + } else if roomMember.userId == self.mainSession.myUser.userId { + let selfMentionString = NSAttributedString(string: "/me", + attributes: [.font: inputToolbar.textDefaultFont ?? ThemeService.shared().theme.fonts.body]) + newAttributedString.append(selfMentionString) + } else { + newAttributedString.append(StringPillsUtils.mentionPill(withRoomMember: roomMember, + andUrl: permalink, + isCurrentUser: false)) + let colon = NSAttributedString(string: ": ", + attributes: [.font: inputToolbar.textDefaultFont ?? ThemeService.shared().theme.fonts.body]) + newAttributedString.append(colon) + } + + inputToolbar.attributedTextMessage = newAttributedString + inputToolbar.becomeFirstResponder() + } + + @objc func sendAttributedTextMessage(_ attributedTextMsg: NSAttributedString) { + let eventModified = self.roomDataSource.event(withEventId: customizedRoomDataSource?.selectedEventId) + self.setupRoomDataSource { roomDataSource in + guard let roomDataSource = roomDataSource as? RoomDataSource else { return } + + if self.inputToolbar?.sendMode == RoomInputToolbarViewSendModeReply, let eventModified = eventModified { + roomDataSource.sendReply(to: eventModified, + withAttributedTextMessage: attributedTextMsg) { response in + switch response { + case .success: + break + case .failure: + MXLog.error("[RoomViewController] sendAttributedTextMessage failed while updating event: \(eventModified.eventId ?? "N/A")") + } + } + } else if self.inputToolbar?.sendMode == RoomInputToolbarViewSendModeEdit, let eventModified = eventModified { + roomDataSource.replaceAttributedTextMessage( + for: eventModified, + withAttributedTextMessage: attributedTextMsg, + success: { _ in + // + }, + failure: { _ in + MXLog.error("[RoomViewController] sendAttributedTextMessage failed while updating event: \(eventModified.eventId ?? "N/A")") + }) + } else { + roomDataSource.sendAttributedTextMessage(attributedTextMsg) { response in + switch response { + case .success: + break + case .failure: + MXLog.error("[RoomViewController] sendAttributedTextMessage failed") + } + } + } + + if self.customizedRoomDataSource?.selectedEventId != nil { + self.cancelEventSelection() + } + } + } +} + +private extension RoomViewController { + var inputToolbar: RoomInputToolbarView? { + return self.inputToolbarView as? RoomInputToolbarView + } +} diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift index 8a1097647..f03b9d371 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarTextView.swift @@ -51,6 +51,13 @@ class RoomInputToolbarTextView: UITextView { } override var text: String! { + didSet { + assert(false) + updateUI() + } + } + + override var attributedText: NSAttributedString! { didSet { updateUI() } @@ -89,7 +96,7 @@ class RoomInputToolbarTextView: UITextView { override func draw(_ rect: CGRect) { super.draw(rect) - guard text.isEmpty, let placeholder = placeholder else { + guard attributedText.length == 0, let placeholder = placeholder else { return } diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index e3feed12e..af00b0724 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -19,6 +19,7 @@ #import "MediaPickerViewController.h" @class RoomActionsBar; +@class RoomInputToolbarView; /** Destination of the message in the composer @@ -54,6 +55,8 @@ typedef enum : NSUInteger */ - (void)roomInputToolbarViewDidOpenActionMenu:(MXKRoomInputToolbarView*)toolbarView; +- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage; + @end /** @@ -102,6 +105,13 @@ typedef enum : NSUInteger */ @property (nonatomic, weak, readonly) UIButton *attachMediaButton; +/** + The current attributed text message in message composer. + */ +@property (nonatomic) NSAttributedString *attributedTextMessage; + +@property (nonatomic, readonly) 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 cc00383e4..653ca2d9b 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -77,7 +77,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; self.isEncryptionEnabled = _isEncryptionEnabled; - [self updateUIWithTextMessage:nil animated:NO]; + [self updateUIWithAttributedTextMessage:nil animated:NO]; self.textView.toolbarDelegate = self; @@ -154,18 +154,41 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; - (void)setTextMessage:(NSString *)textMessage { - [super setTextMessage:textMessage]; - - self.textView.text = textMessage; - [self updateUIWithTextMessage:textMessage animated:YES]; + if (!textMessage) + { + [self setAttributedTextMessage:nil]; + } +} + +- (void)setAttributedTextMessage:(NSAttributedString *)attributedTextMessage +{ + self.textView.attributedText = attributedTextMessage; + [self updateUIWithAttributedTextMessage:attributedTextMessage animated:YES]; [self textViewDidChange:self.textView]; } +- (NSAttributedString *)attributedTextMessage +{ + return self.textView.attributedText; +} + - (NSString *)textMessage { return self.textView.text; } +- (UIFont *)textDefaultFont +{ + if (self.textView.font) + { + return self.textView.font; + } + else + { + return ThemeService.shared.theme.fonts.body; + } +} + - (void)setIsEncryptionEnabled:(BOOL)isEncryptionEnabled { _isEncryptionEnabled = isEncryptionEnabled; @@ -329,9 +352,10 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { - NSString *newText = [textView.text stringByReplacingCharactersInRange:range withString:text]; - [self updateUIWithTextMessage:newText animated:YES]; - + NSMutableAttributedString *newText = [[NSMutableAttributedString alloc] initWithAttributedString:textView.attributedText]; + [newText replaceCharactersInRange:range withString:text]; + [self updateUIWithAttributedTextMessage:newText animated:YES]; + return YES; } @@ -466,15 +490,15 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; #pragma mark - Private -- (void)updateUIWithTextMessage:(NSString *)textMessage animated:(BOOL)animated +- (void)updateUIWithAttributedTextMessage:(NSAttributedString *)attributedTextMessage animated:(BOOL)animated { self.actionMenuOpened = NO; [UIView animateWithDuration:(animated ? 0.15f : 0.0f) animations:^{ - self.rightInputToolbarButton.alpha = textMessage.length ? 1.0f : 0.0f; - self.rightInputToolbarButton.enabled = textMessage.length; + self.rightInputToolbarButton.alpha = attributedTextMessage.length ? 1.0f : 0.0f; + self.rightInputToolbarButton.enabled = attributedTextMessage.length; - self.voiceMessageToolbarView.alpha = textMessage.length ? 0.0f : 1.0; + self.voiceMessageToolbarView.alpha = attributedTextMessage.length ? 0.0f : 1.0; }]; } diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift new file mode 100644 index 000000000..1f8040cea --- /dev/null +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.swift @@ -0,0 +1,33 @@ +// +// Copyright 2022 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 Foundation + +extension RoomInputToolbarView { + open override func sendCurrentMessage() { + // TODO: trigger auto correct ? + + // Send message if any. + if let messageToSend = self.attributedTextMessage, messageToSend.length > 0 { + self.delegate.roomInputToolbarView(self, sendAttributedTextMessage: messageToSend) + } + + // Reset message, disable view animation during the update to prevent placeholder distorsion. + UIView.setAnimationsEnabled(false) + self.attributedTextMessage = nil + UIView.setAnimationsEnabled(true) + } +}