diff --git a/CHANGES.rst b/CHANGES.rst index 5e3af3b620..52cfc00182 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,7 @@ Changes in Matrix iOS SDK in 0.11.0 () Improvements: * MXRestClient: Add Matrix filter API. +* MXRoom: Add send reply with text message (vector-im/riot-ios#1911). * MXRoom: Add an asynchronous methods for liveTimeline, state and members. * MXRoom: Add methods to manage the room liveTimeline listeners synchronously. * MXRoomState: Add a membersCount property to store members stats independently from MXRoomMember objects. diff --git a/MatrixSDK.xcodeproj/project.pbxproj b/MatrixSDK.xcodeproj/project.pbxproj index bd3d8b2067..b717074238 100644 --- a/MatrixSDK.xcodeproj/project.pbxproj +++ b/MatrixSDK.xcodeproj/project.pbxproj @@ -221,6 +221,9 @@ 9274AFE81EE580240009BEB6 /* MXCallKitAdapter.h in Headers */ = {isa = PBXBuildFile; fileRef = 9274AFE61EE580240009BEB6 /* MXCallKitAdapter.h */; settings = {ATTRIBUTES = (Public, ); }; }; 9274AFE91EE580240009BEB6 /* MXCallKitAdapter.m in Sources */ = {isa = PBXBuildFile; fileRef = 9274AFE71EE580240009BEB6 /* MXCallKitAdapter.m */; }; A23A8594855481FEFA0E9A22 /* libPods-MatrixSDK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E1674C6FF8BBF074E7F76059 /* libPods-MatrixSDK.a */; }; + B17285792100C8EA0052C51E /* MXSendReplyEventStringsLocalizable.h in Headers */ = {isa = PBXBuildFile; fileRef = B17285782100C88A0052C51E /* MXSendReplyEventStringsLocalizable.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B172857C2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.h in Headers */ = {isa = PBXBuildFile; fileRef = B172857A2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.h */; }; + B172857D2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.m in Sources */ = {isa = PBXBuildFile; fileRef = B172857B2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.m */; }; C60165381E3AA57900B92CFA /* MXSDKOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = F0C34CB91C18C80000C36F09 /* MXSDKOptions.h */; settings = {ATTRIBUTES = (Public, ); }; }; C602B58C1F2268F700B67D87 /* MXRoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = C602B58B1F2268F700B67D87 /* MXRoom.swift */; }; C602B58E1F22A8D700B67D87 /* MXImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C602B58D1F22A8D700B67D87 /* MXImage.swift */; }; @@ -504,6 +507,9 @@ 92634B811EF2E3C400DB9F60 /* MXCallKitConfiguration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MXCallKitConfiguration.m; sourceTree = ""; }; 9274AFE61EE580240009BEB6 /* MXCallKitAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MXCallKitAdapter.h; sourceTree = ""; }; 9274AFE71EE580240009BEB6 /* MXCallKitAdapter.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MXCallKitAdapter.m; sourceTree = ""; }; + B17285782100C88A0052C51E /* MXSendReplyEventStringsLocalizable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MXSendReplyEventStringsLocalizable.h; sourceTree = ""; }; + B172857A2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MXSendReplyEventDefaultStringLocalizations.h; sourceTree = ""; }; + B172857B2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXSendReplyEventDefaultStringLocalizations.m; sourceTree = ""; }; B1F1AE550CF4C15B653DDE6E /* Pods-MatrixSDKTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MatrixSDKTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-MatrixSDKTests/Pods-MatrixSDKTests.debug.xcconfig"; sourceTree = ""; }; C602B58B1F2268F700B67D87 /* MXRoom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MXRoom.swift; sourceTree = ""; }; C602B58D1F22A8D700B67D87 /* MXImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MXImage.swift; sourceTree = ""; }; @@ -629,6 +635,9 @@ 329FB17E1A0B665800A5E88E /* MXUser.m */, 327137251A24D50A00DB6757 /* MXMyUser.h */, 327137261A24D50A00DB6757 /* MXMyUser.m */, + B17285782100C88A0052C51E /* MXSendReplyEventStringsLocalizable.h */, + B172857A2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.h */, + B172857B2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.m */, ); path = Data; sourceTree = ""; @@ -1190,6 +1199,7 @@ 32114A851A262CE000FF2EC4 /* MXStore.h in Headers */, 32A151391DAD292400400192 /* MXMegolmEncryption.h in Headers */, F03EF5041DF01596009DF592 /* MXLRUCache.h in Headers */, + B172857C2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.h in Headers */, 329FB1791A0A74B100A5E88E /* MXTools.h in Headers */, 322691321E5EF77D00966A6E /* MXDeviceListOperation.h in Headers */, 32481A841C03572900782AD3 /* MXRoomAccountData.h in Headers */, @@ -1199,6 +1209,7 @@ 323F3F9420D3F0C700D26D6A /* MXRoomEventFilter.h in Headers */, 32A9E8251EF4026E0081358A /* MXUIKitBackgroundModeHandler.h in Headers */, 325D1C261DFECE0D0070B8BF /* MXCrypto_Private.h in Headers */, + B17285792100C8EA0052C51E /* MXSendReplyEventStringsLocalizable.h in Headers */, 32A151521DAF8A7200400192 /* MXQueuedEncryption.h in Headers */, 32A30B181FB4813400C8309E /* MXIncomingRoomKeyRequestManager.h in Headers */, F03EF5081DF071D5009DF592 /* MXEncryptedAttachments.h in Headers */, @@ -1458,6 +1469,7 @@ 32A1515C1DB525DA00400192 /* NSObject+sortedKeys.m in Sources */, 32618E7C20EFA45B00E1D2EA /* MXRoomMembers.m in Sources */, F03EF5091DF071D5009DF592 /* MXEncryptedAttachments.m in Sources */, + B172857D2100D4F60052C51E /* MXSendReplyEventDefaultStringLocalizations.m in Sources */, 32FA10CF1FA1C9F700E54233 /* MXOutgoingRoomKeyRequest.m in Sources */, 32322A4C1E575F65005DD155 /* MXAllowedCertificates.m in Sources */, F03EF5051DF01596009DF592 /* MXLRUCache.m in Sources */, diff --git a/MatrixSDK/Contrib/Swift/Data/MXRoom.swift b/MatrixSDK/Contrib/Swift/Data/MXRoom.swift index 7b17223419..d887b6fa7a 100644 --- a/MatrixSDK/Contrib/Swift/Data/MXRoom.swift +++ b/MatrixSDK/Contrib/Swift/Data/MXRoom.swift @@ -604,6 +604,39 @@ public extension MXRoom { return __reportEvent(eventId, score: score, reason: reason, success: currySuccess(completion), failure: curryFailure(completion)) } + /** + Send a reply to an event with text message to the room. + + It's only supported to reply to event with 'm.room.message' event type and following message types: 'm.text', 'm.text', 'm.emote', 'm.notice', 'm.image', 'm.file', 'm.video', 'm.audio'. + + - parameters: + - eventToReply: The event to reply. + - textMessage: The text to send. + - formattedTextMessage: The optional HTML formatted string of the text to send. + - stringLocalizations: String localizations used when building reply message. + - localEcho: a pointer to an MXEvent object. + + When the event type is `MXEventType.roomMessage`, this pointer is set to an actual + MXEvent object containing the local created event which should be used to echo the + message in the messages list until the resulting event comes through the server sync. + For information, the identifier of the created local event has the prefix: + `kMXEventLocalEventIdPrefix`. + + You may specify nil for this parameter if you do not want this information. + + You may provide your own MXEvent object, in this case only its send state is updated. + + When the event type is `kMXEventTypeStringRoomEncrypted`, no local event is created. + + - completion: A block object called when the operation completes. + - response: Provides the event id of the event generated on the home server on success + + - returns: a `MXHTTPOperation` instance. + */ + @nonobjc @discardableResult func sendReply(to eventToReply: MXEvent, textMessage: String, formattedTextMessage: String?, stringLocalizations: MXSendReplyEventStringsLocalizable?, localEcho: inout MXEvent?, completion: @escaping (_ response: MXResponse) -> Void) -> MXHTTPOperation { + return __sendReply(to: eventToReply, withTextMessage: textMessage, formattedTextMessage: formattedTextMessage, stringLocalizations: stringLocalizations, localEcho: &localEcho, success: currySuccess(completion), failure: curryFailure(completion)) + } + // MARK: - Room Tags Operations diff --git a/MatrixSDK/Data/MXRoom.h b/MatrixSDK/Data/MXRoom.h index eafda62760..b11389c6db 100644 --- a/MatrixSDK/Data/MXRoom.h +++ b/MatrixSDK/Data/MXRoom.h @@ -37,6 +37,7 @@ #import "MXEventTimeline.h" #import "MXEventsEnumerator.h" #import "MXCryptoConstants.h" +#import "MXSendReplyEventStringsLocalizable.h" @class MXRoom; @class MXSession; @@ -764,6 +765,39 @@ FOUNDATION_EXPORT NSString *const kMXRoomDidFlushDataNotification; success:(void (^)(void))success failure:(void (^)(NSError *error))failure; +/** + Indicate if replying to the provided event is supported. + Only event of type 'MXEventTypeRoomMessage' are supported for the moment, and for certain msgtype. + + @param eventToReply the event to reply to + @return YES if it is possible to reply to this event + */ +- (BOOL)canReplyToEvent:(MXEvent *)eventToReply; + +/** + Send a reply to an event with text message to the room. + + It's only supported to reply to event with 'm.room.message' event type and following message types: 'm.text', 'm.text', 'm.emote', 'm.notice', 'm.image', 'm.file', 'm.video', 'm.audio'. + + @param eventToReply The event to reply. + @param textMessage the text to send. + @param formattedTextMessage the optional HTML formatted string of the text to send. + @param stringLocalizations string localizations used when building reply message. + @param localEcho a pointer to a MXEvent object (@see sendMessageWithContent: for details). + @param success A block object called when the operation succeeds. It returns + the event id of the event generated on the home server + @param failure A block object called when the operation fails. + + @return a MXHTTPOperation instance. + */ +- (MXHTTPOperation*)sendReplyToEvent:(MXEvent*)eventToReply + withTextMessage:(NSString*)textMessage + formattedTextMessage:(NSString*)formattedTextMessage + stringLocalizations:(id)stringLocalizations + localEcho:(MXEvent**)localEcho + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure NS_REFINED_FOR_SWIFT; + #pragma mark - Events listeners on the live timeline /** @@ -795,7 +829,6 @@ FOUNDATION_EXPORT NSString *const kMXRoomDidFlushDataNotification; */ - (void)removeAllListeners; - #pragma mark - Events timeline /** Open a new `MXEventTimeline` instance around the passed event. diff --git a/MatrixSDK/Data/MXRoom.m b/MatrixSDK/Data/MXRoom.m index 59892e1a39..5664f73f8a 100644 --- a/MatrixSDK/Data/MXRoom.m +++ b/MatrixSDK/Data/MXRoom.m @@ -27,6 +27,7 @@ #import "MXMediaManager.h" #import "MXRoomOperation.h" +#import "MXSendReplyEventDefaultStringLocalizations.h" #import "MXError.h" @@ -458,6 +459,23 @@ - (MXHTTPOperation*)sendEventOfType:(MXEventTypeString)eventTypeString } else { + NSDictionary *relatesToJSON = nil; + + NSDictionary *contentCopyToEncrypt = nil; + + // Store the "m.relates_to" data and remove them from event clear content before encrypting the event content + if (contentCopy[@"m.relates_to"]) + { + relatesToJSON = contentCopy[@"m.relates_to"]; + NSMutableDictionary *updatedContent = [contentCopy mutableCopy]; + updatedContent[@"m.relates_to"] = nil; + contentCopyToEncrypt = [updatedContent copy]; + } + else + { + contentCopyToEncrypt = contentCopy; + } + // Check whether a local echo is required if ([eventTypeString isEqualToString:kMXEventTypeStringRoomMessage] || [eventTypeString isEqualToString:kMXEventTypeStringSticker]) @@ -488,16 +506,30 @@ - (MXHTTPOperation*)sendEventOfType:(MXEventTypeString)eventTypeString MXStrongifyAndReturnIfNil(self); MXWeakify(self); - MXHTTPOperation *operation = [self->mxSession.crypto encryptEventContent:contentCopy withType:eventTypeString inRoom:self success:^(NSDictionary *encryptedContent, NSString *encryptedEventType) { + MXHTTPOperation *operation = [self->mxSession.crypto encryptEventContent:contentCopyToEncrypt withType:eventTypeString inRoom:self success:^(NSDictionary *encryptedContent, NSString *encryptedEventType) { MXStrongifyAndReturnIfNil(self); + NSDictionary *finalEncryptedContent; + + // Add "m.relates_to" to encrypted event content if any + if (relatesToJSON) + { + NSMutableDictionary *updatedEncryptedContent = [encryptedContent mutableCopy]; + updatedEncryptedContent[@"m.relates_to"] = relatesToJSON; + finalEncryptedContent = [updatedEncryptedContent copy]; + } + else + { + finalEncryptedContent = encryptedContent; + } + if (event) { // Encapsulate the resulting event in a fake encrypted event MXEvent *clearEvent = [self fakeEventWithEventId:event.eventId eventType:eventTypeString andContent:event.content]; event.wireType = encryptedEventType; - event.wireContent = encryptedContent; + event.wireContent = finalEncryptedContent; MXEventDecryptionResult *decryptionResult = [[MXEventDecryptionResult alloc] init]; decryptionResult.clearEvent = clearEvent.JSONDictionary; @@ -514,7 +546,7 @@ - (MXHTTPOperation*)sendEventOfType:(MXEventTypeString)eventTypeString } // Send the encrypted content - MXHTTPOperation *operation2 = [self _sendEventOfType:encryptedEventType content:encryptedContent txnId:event.eventId success:onSuccess failure:onFailure]; + MXHTTPOperation *operation2 = [self _sendEventOfType:encryptedEventType content:finalEncryptedContent txnId:event.eventId success:onSuccess failure:onFailure]; if (operation2) { // Mutate MXHTTPOperation so that the user can cancel this new operation @@ -1551,6 +1583,357 @@ - (MXHTTPOperation*)setRelatedGroups:(NSArray*)relatedGroups return [mxSession.matrixRestClient setRoomRelatedGroups:self.roomId relatedGroups:relatedGroups success:success failure:failure]; } +- (MXHTTPOperation*)sendReplyToEvent:(MXEvent*)eventToReply + withTextMessage:(NSString*)textMessage + formattedTextMessage:(NSString*)formattedTextMessage + stringLocalizations:(id)stringLocalizations + localEcho:(MXEvent**)localEcho + success:(void (^)(NSString *eventId))success + failure:(void (^)(NSError *error))failure +{ + if (![self canReplyToEvent:eventToReply]) + { + NSLog(@"[MXRoom] Send reply to this event is not supported"); + return nil; + } + + id finalStringLocalizations; + + if (stringLocalizations) + { + finalStringLocalizations = stringLocalizations; + } + else + { + finalStringLocalizations = [MXSendReplyEventDefaultStringLocalizations new]; + } + + MXHTTPOperation* operation = nil; + + NSString *replyToBody; + NSString *replyToFormattedBody; + + [self getReplyContentBodiesWithEventToReply:eventToReply + textMessage:textMessage + formattedTextMessage:formattedTextMessage + replyContentBody:&replyToBody + replyContentFormattedBody:&replyToFormattedBody + stringLocalizations:finalStringLocalizations]; + + if (replyToBody && replyToFormattedBody) + { + NSString *eventId = eventToReply.eventId; + + NSDictionary *relatesToDict = @{ @"m.in_reply_to" : + @{ + @"event_id" : eventId + } + }; + + NSMutableDictionary *msgContent = [NSMutableDictionary dictionary]; + + msgContent[@"format"] = kMXRoomMessageFormatHTML; + msgContent[@"msgtype"] = kMXMessageTypeText; + msgContent[@"body"] = replyToBody; + msgContent[@"formatted_body"] = replyToFormattedBody; + msgContent[@"m.relates_to"] = relatesToDict; + + operation = [self sendMessageWithContent:msgContent + localEcho:localEcho + success:success + failure:failure]; + } + else + { + NSLog(@"[MXRoom] Fail to generate reply body and formatted body"); + } + + return operation; +} + +/** + Build reply to body and formatted body. + + @param eventToReply the event to reply. Should be 'm.room.message' event type. + @param textMessage the text to send. + @param formattedTextMessage the optional HTML formatted string of the text to send. + @param replyContentBody reply string of the text to send. + @param replyContentFormattedBody reply HTML formatted string of the text to send. + @param stringLocalizations string localizations used when building reply content bodies. + + */ +- (void)getReplyContentBodiesWithEventToReply:(MXEvent*)eventToReply + textMessage:(NSString*)textMessage + formattedTextMessage:(NSString*)formattedTextMessage + replyContentBody:(NSString**)replyContentBody + replyContentFormattedBody:(NSString**)replyContentFormattedBody + stringLocalizations:(id)stringLocalizations +{ + NSString *msgtype; + MXJSONModelSetString(msgtype, eventToReply.content[@"msgtype"]); + + if (!msgtype) + { + return; + } + + BOOL eventToReplyIsAlreadyAReply = eventToReply.content[@"m.relates_to"][@"m.in_reply_to"][@"event_id"] != nil; + BOOL isSenderMessageAnEmote = [msgtype isEqualToString:kMXMessageTypeEmote]; + + NSString *senderMessageBody; + NSString *senderMessageFormattedBody; + + if ([msgtype isEqualToString:kMXMessageTypeText] + || [msgtype isEqualToString:kMXMessageTypeNotice] + || [msgtype isEqualToString:kMXMessageTypeEmote]) + { + NSString *eventToReplyMessageBody = eventToReply.content[@"body"]; + NSString *eventToReplyMessageFormattedBody = eventToReply.content[@"formatted_body"]; + + senderMessageBody = eventToReplyMessageBody; + senderMessageFormattedBody = eventToReplyMessageFormattedBody ?: eventToReplyMessageBody; + } + else if ([msgtype isEqualToString:kMXMessageTypeImage]) + { + senderMessageBody = stringLocalizations.senderSentAnImage; + senderMessageFormattedBody = senderMessageBody; + } + else if ([msgtype isEqualToString:kMXMessageTypeVideo]) + { + senderMessageBody = stringLocalizations.senderSentAVideo; + senderMessageFormattedBody = senderMessageBody; + } + else if ([msgtype isEqualToString:kMXMessageTypeAudio]) + { + senderMessageBody = stringLocalizations.senderSentAnAudioFile; + senderMessageFormattedBody = senderMessageBody; + } + else if ([msgtype isEqualToString:kMXMessageTypeFile]) + { + senderMessageBody = stringLocalizations.senderSentAFile; + senderMessageFormattedBody = senderMessageBody; + } + else + { + // Other message types are not supported + NSLog(@"[MXRoom] Reply to message type %@ is not supported", msgtype); + } + + if (senderMessageBody && senderMessageFormattedBody) + { + *replyContentBody = [self replyMessageBodyFromSender:eventToReply.sender + senderMessageBody:senderMessageBody + isSenderMessageAnEmote:isSenderMessageAnEmote + isSenderMessageAReplyTo:eventToReplyIsAlreadyAReply + replyMessage:textMessage]; + + // As formatted body is mandatory for a reply message, use non formatted to build it + NSString *finalFormattedTextMessage = formattedTextMessage ?: textMessage; + + *replyContentFormattedBody = [self replyMessageFormattedBodyFromEventToReply:eventToReply + senderMessageFormattedBody:senderMessageFormattedBody + isSenderMessageAnEmote:isSenderMessageAnEmote + isSenderMessageAReplyTo:eventToReplyIsAlreadyAReply + replyFormattedMessage:finalFormattedTextMessage + stringLocalizations:stringLocalizations]; + } +} + +/** + Build reply body. + + Example of reply body: + `> <@sender:matrix.org> sent an image.\n\nReply message` + + @param sender The sender of the message. + @param senderMessageBody The message body of the sender. + @param isSenderMessageAnEmote Indicate if the sender message is an emote (/me). + @param isSenderMessageAReplyTo Indicate if the sender message is already a reply to message. + @param replyMessage The response for the sender message. + + @return Reply message body. + */ +- (NSString*)replyMessageBodyFromSender:(NSString*)sender + senderMessageBody:(NSString*)senderMessageBody + isSenderMessageAnEmote:(BOOL)isSenderMessageAnEmote + isSenderMessageAReplyTo:(BOOL)isSenderMessageAReplyTo + replyMessage:(NSString*)replyMessage +{ + // Sender reply body split by lines + NSMutableArray *senderReplyBodyLines = [[senderMessageBody componentsSeparatedByString:@"\n"] mutableCopy]; + + // Strip previous reply to, if the event was already a reply + if (isSenderMessageAReplyTo) + { + // Removes lines beginning with `> ` until you reach one that doesn't. + while (senderReplyBodyLines.count && [senderReplyBodyLines.firstObject hasPrefix:@"> "]) + { + [senderReplyBodyLines removeObjectAtIndex:0]; + } + + // Reply fallback has a blank line after it, so remove it to prevent leading newline + if (senderReplyBodyLines.firstObject.length == 0) + { + [senderReplyBodyLines removeObjectAtIndex:0]; + } + } + + // Build sender message reply body part + + // Add user id on first line + NSString *firstLine = senderReplyBodyLines.firstObject; + if (firstLine) + { + NSString *newFirstLine; + + if (isSenderMessageAnEmote) + { + newFirstLine = [NSString stringWithFormat:@"* <%@> %@", sender, firstLine]; + } + else + { + newFirstLine = [NSString stringWithFormat:@"<%@> %@", sender, firstLine]; + } + senderReplyBodyLines[0] = newFirstLine; + } + + NSUInteger messageToReplyBodyLineIndex = 0; + + // Add reply `> ` sequence at begining of each line + for (NSString *messageToReplyBodyLine in [senderReplyBodyLines copy]) + { + senderReplyBodyLines[messageToReplyBodyLineIndex] = [NSString stringWithFormat:@"> %@", messageToReplyBodyLine]; + messageToReplyBodyLineIndex++; + } + + // Build final message body with sender message and reply message + NSMutableString *messageBody = [NSMutableString string]; + [messageBody appendString:[senderReplyBodyLines componentsJoinedByString:@"\n"]]; + [messageBody appendString:@"\n\n"]; // Add separator between sender message and reply message + [messageBody appendString:replyMessage]; + + return [messageBody copy]; +} + +/** + Build reply formatted body. + + Example of reply formatted body: + `
In reply to @sender:matrix.org
sent an image.
Reply message` + + @param eventToReply The sender event to reply. + @param senderMessageFormattedBody The message body of the sender. + @param isSenderMessageAnEmote Indicate if the sender message is an emote (/me). + @param isSenderMessageAReplyTo Indicate if the sender message is already a reply to message. + @param replyFormattedMessage The response for the sender message. HTML formatted string if any otherwise non formatted string as reply formatted body is mandatory. + @param stringLocalizations string localizations used when building formatted body. + + @return reply message body. + */ +- (NSString*)replyMessageFormattedBodyFromEventToReply:(MXEvent*)eventToReply + senderMessageFormattedBody:(NSString*)senderMessageFormattedBody + isSenderMessageAnEmote:(BOOL)isSenderMessageAnEmote + isSenderMessageAReplyTo:(BOOL)isSenderMessageAReplyTo + replyFormattedMessage:(NSString*)replyFormattedMessage + stringLocalizations:(id)stringLocalizations +{ + NSString *eventId = eventToReply.eventId; + NSString *roomId = eventToReply.roomId; + NSString *sender = eventToReply.sender; + + if (!eventId || !roomId || !sender) + { + NSLog(@"[MXRoom] roomId, eventId and sender cound not be nil"); + return nil; + } + + NSString *replySenderMessageFormattedBody; + + // Strip previous reply to, if the event was already a reply + if (isSenderMessageAReplyTo) + { + NSError *error = nil; + NSRegularExpression *replyRegex = [NSRegularExpression regularExpressionWithPattern:@"^.*" options:NSRegularExpressionCaseInsensitive error:&error]; + NSString *senderMessageFormattedBodyWithoutReply = [replyRegex stringByReplacingMatchesInString:senderMessageFormattedBody options:0 range:NSMakeRange(0, senderMessageFormattedBody.length) withTemplate:@""]; + + if (error) + { + NSLog(@"[MXRoom] Fail to strip previous reply to message"); + } + + if (senderMessageFormattedBodyWithoutReply) + { + replySenderMessageFormattedBody = senderMessageFormattedBodyWithoutReply; + } + } + else + { + replySenderMessageFormattedBody = senderMessageFormattedBody; + } + + // Build reply formatted body + + NSString *eventPermalink = [MXTools permalinkToEvent:eventId inRoom:roomId]; + NSString *userPermalink = [MXTools permalinkToUserWithUserId:sender]; + + NSMutableString *replyMessageFormattedBody = [NSMutableString string]; + + // Start reply quote + [replyMessageFormattedBody appendString:@"
"]; + + // Add event link + [replyMessageFormattedBody appendFormat:@"%@ ", eventPermalink, stringLocalizations.messageToReplyToPrefix]; + + if (isSenderMessageAnEmote) + { + [replyMessageFormattedBody appendString:@"* "]; + } + + // Add user link + [replyMessageFormattedBody appendFormat:@"%@", userPermalink, sender]; + + [replyMessageFormattedBody appendString:@"
"]; + + // Add sender message + [replyMessageFormattedBody appendString:replySenderMessageFormattedBody]; + + // End reply quote + [replyMessageFormattedBody appendString:@"
"]; + + // Add reply message + [replyMessageFormattedBody appendString:replyFormattedMessage]; + + return replyMessageFormattedBody; +} + +- (BOOL)canReplyToEvent:(MXEvent *)eventToReply +{ + if (eventToReply.eventType != MXEventTypeRoomMessage) + { + return NO; + } + + BOOL canReplyToEvent = NO; + + NSString *messageType = eventToReply.content[@"msgtype"]; + + if (messageType) + { + NSArray *supportedMessageTypes = @[ + kMXMessageTypeText, + kMXMessageTypeNotice, + kMXMessageTypeEmote, + kMXMessageTypeImage, + kMXMessageTypeVideo, + kMXMessageTypeAudio, + kMXMessageTypeFile + ]; + + canReplyToEvent = [supportedMessageTypes containsObject:messageType]; + } + + return canReplyToEvent; +} #pragma mark - Message order preserving /** diff --git a/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.h b/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.h new file mode 100644 index 0000000000..ef42655849 --- /dev/null +++ b/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.h @@ -0,0 +1,31 @@ +/* + 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 Foundation; +#import "MXSendReplyEventStringsLocalizable.h" + +/** + A `MXSendReplyEventDefaultStringLocalizations` instance represents default localization strings used when send reply event to a message in a room. + */ +@interface MXSendReplyEventDefaultStringLocalizations : NSObject + +@property (copy, readonly, nonnull) NSString *senderSentAnImage; +@property (copy, readonly, nonnull) NSString *senderSentAVideo; +@property (copy, readonly, nonnull) NSString *senderSentAnAudioFile; +@property (copy, readonly, nonnull) NSString *senderSentAFile; +@property (copy, readonly, nonnull) NSString *messageToReplyToPrefix; + +@end diff --git a/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.m b/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.m new file mode 100644 index 0000000000..ffc7475e4e --- /dev/null +++ b/MatrixSDK/Data/MXSendReplyEventDefaultStringLocalizations.m @@ -0,0 +1,34 @@ +/* + 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 "MXSendReplyEventDefaultStringLocalizations.h" + +@implementation MXSendReplyEventDefaultStringLocalizations + +- (instancetype)init +{ + self = [super init]; + if (self) { + _senderSentAnImage = @"sent an image."; + _senderSentAVideo = @"sent a video."; + _senderSentAnAudioFile = @"sent an audio file."; + _senderSentAFile = @"sent a file."; + _messageToReplyToPrefix = @"In reply to"; + } + return self; +} + +@end diff --git a/MatrixSDK/Data/MXSendReplyEventStringsLocalizable.h b/MatrixSDK/Data/MXSendReplyEventStringsLocalizable.h new file mode 100644 index 0000000000..6d5efc7b2d --- /dev/null +++ b/MatrixSDK/Data/MXSendReplyEventStringsLocalizable.h @@ -0,0 +1,33 @@ +/* + 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 Foundation; + +/** + The `MXSendReplyEventStringsLocalizable` protocol defines an interface that must be implemented + to provide string localizations when send reply event to a message in a room. + */ +@protocol MXSendReplyEventStringsLocalizable + +@required + +@property (copy, readonly, nonnull) NSString *senderSentAnImage; +@property (copy, readonly, nonnull) NSString *senderSentAVideo; +@property (copy, readonly, nonnull) NSString *senderSentAnAudioFile; +@property (copy, readonly, nonnull) NSString *senderSentAFile; +@property (copy, readonly, nonnull) NSString *messageToReplyToPrefix; + +@end diff --git a/MatrixSDK/JSONModels/MXEvent.m b/MatrixSDK/JSONModels/MXEvent.m index aec652873d..e0d016d59f 100644 --- a/MatrixSDK/JSONModels/MXEvent.m +++ b/MatrixSDK/JSONModels/MXEvent.m @@ -530,7 +530,25 @@ - (void)setClearData:(MXEventDecryptionResult *)decryptionResult _clearEvent = nil; if (decryptionResult.clearEvent) { - _clearEvent = [MXEvent modelFromJSON:decryptionResult.clearEvent]; + NSDictionary *decryptionClearEventJSON; + NSDictionary *encryptedContentRelatesToJSON = _wireContent[@"m.relates_to"]; + + // Add "m.relates_to" data from e2e event to the unencrypted content event + if (encryptedContentRelatesToJSON) + { + NSMutableDictionary *decryptionClearEventUpdatedJSON = [decryptionResult.clearEvent mutableCopy]; + NSMutableDictionary *clearEventContentUpdatedJSON = [decryptionClearEventUpdatedJSON[@"content"] mutableCopy]; + + clearEventContentUpdatedJSON[@"m.relates_to"] = encryptedContentRelatesToJSON; + decryptionClearEventUpdatedJSON[@"content"] = [clearEventContentUpdatedJSON copy]; + decryptionClearEventJSON = [decryptionClearEventUpdatedJSON copy]; + } + else + { + decryptionClearEventJSON = decryptionResult.clearEvent; + } + + _clearEvent = [MXEvent modelFromJSON:decryptionClearEventJSON]; } if (_clearEvent) diff --git a/MatrixSDK/Utils/MXTools.h b/MatrixSDK/Utils/MXTools.h index e3de01b029..35ac769d9e 100644 --- a/MatrixSDK/Utils/MXTools.h +++ b/MatrixSDK/Utils/MXTools.h @@ -134,6 +134,14 @@ FOUNDATION_EXPORT NSString *const kMXToolsRegexStringForMatrixGroupIdentifier; */ + (NSString*)permalinkToEvent:(NSString*)eventId inRoom:(NSString*)roomIdOrAlias; +/* + Return a matrix.to permalink to a user. + + @param userId the id of the user to link to. + @return the matrix.to permalink. + */ ++ (NSString*)permalinkToUserWithUserId:(NSString*)userId; + #pragma mark - File /** diff --git a/MatrixSDK/Utils/MXTools.m b/MatrixSDK/Utils/MXTools.m index 694aef5711..db6c23dc96 100644 --- a/MatrixSDK/Utils/MXTools.m +++ b/MatrixSDK/Utils/MXTools.m @@ -339,6 +339,11 @@ + (NSString *)permalinkToEvent:(NSString *)eventId inRoom:(NSString *)roomIdOrAl } ++ (NSString*)permalinkToUserWithUserId:(NSString*)userId +{ + return [NSString stringWithFormat:@"%@/#/%@", kMXMatrixDotToUrl, userId]; +} + #pragma mark - File // return an array of files attributes diff --git a/MatrixSDKTests/MXCryptoTests.m b/MatrixSDKTests/MXCryptoTests.m index 1413bd6949..cefa88a96e 100644 --- a/MatrixSDKTests/MXCryptoTests.m +++ b/MatrixSDKTests/MXCryptoTests.m @@ -27,6 +27,8 @@ #import "MXFileStore.h" #import "MXSDKOptions.h" +#import "MXTools.h" +#import "MXSendReplyEventDefaultStringLocalizations.h" #if 1 // MX_CRYPTO autamatic definiton does not work well for tests so force it //#ifdef MX_CRYPTO @@ -1404,6 +1406,160 @@ - (void)testBlackListUnverifiedDevices } +// Test method copy from MXRoomTests -testSendReplyToTextMessage +- (void)testSendReplyToTextMessage +{ + NSString *firstMessage = @"**First message!**"; + NSString *firstFormattedMessage = @"

First message!

"; + + NSString *secondMessageReplyToFirst = @"**Reply to first message**"; + NSString *secondMessageFormattedReplyToFirst = @"

Reply to first message

"; + + NSString *expectedSecondEventBodyStringFormat = @"> <%@> **First message!**\n\n**Reply to first message**"; + NSString *expectedSecondEventFormattedBodyStringFormat = @"
In reply to %@

First message!

Reply to first message

"; + + NSString *thirdMessageReplyToSecond = @"**Reply to second message**"; + NSString *thirdMessageFormattedReplyToSecond = @"

Reply to second message

"; + + NSString *expectedThirdEventBodyStringFormat = @"> <%@> **Reply to first message**\n\n**Reply to second message**"; + NSString *expectedThirdEventFormattedBodyStringFormat = @"
In reply to %@

Reply to first message

Reply to second message

"; + + MXSendReplyEventDefaultStringLocalizations *defaultStringLocalizations = [MXSendReplyEventDefaultStringLocalizations new]; + + __block NSUInteger successFullfillCount = 0; + NSUInteger expectedSuccessFulfillCount = 2; // Bob and Alice have finished their tests + + [matrixSDKTestsE2EData doE2ETestWithAliceAndBobInARoom:self cryptedBob:YES warnOnUnknowDevices:NO readyToTest:^(MXSession *aliceSession, MXSession *bobSession, NSString *roomId, XCTestExpectation *expectation) { + + void (^testExpectationFullfillIfComplete)(void) = ^() { + successFullfillCount++; + if (successFullfillCount == expectedSuccessFulfillCount) + { + [expectation fulfill]; + } + }; + + __block NSUInteger messageCount = 0; + __block NSUInteger messageCountFromAlice = 0; + + MXRoom *roomFromBobPOV = [bobSession roomWithRoomId:roomId]; + MXRoom *roomFromAlicePOV = [aliceSession roomWithRoomId:roomId]; + + // Listen to messages from Bob POV + [roomFromBobPOV.liveTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomMessage] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + messageCount++; + + if (messageCount == 1) + { + __block MXEvent *localEchoEvent = nil; + + // Reply to first message + [roomFromBobPOV sendReplyToEvent:event withTextMessage:secondMessageReplyToFirst formattedTextMessage:secondMessageFormattedReplyToFirst stringLocalizations:defaultStringLocalizations localEcho:&localEchoEvent success:^(NSString *eventId) { + NSLog(@"Send reply to first message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + + XCTAssertNotNil(localEchoEvent); + + NSString *firstEventId = event.eventId; + NSString *firstEventSender = event.sender; + + NSString *secondEventBody = localEchoEvent.content[@"body"]; + NSString *secondEventFormattedBody = localEchoEvent.content[@"formatted_body"]; + NSString *secondEventRelatesToEventId = localEchoEvent.content[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + NSString *secondWiredEventRelatesToEventId = localEchoEvent.wireContent[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + NSString *permalinkToUser = [MXTools permalinkToUserWithUserId:firstEventSender]; + NSString *permalinkToEvent = [MXTools permalinkToEvent:firstEventId inRoom:roomId]; + + NSString *expectedSecondEventBody = [NSString stringWithFormat:expectedSecondEventBodyStringFormat, firstEventSender]; + NSString *expectedSecondEventFormattedBody = [NSString stringWithFormat:expectedSecondEventFormattedBodyStringFormat, permalinkToEvent, permalinkToUser, firstEventSender]; + + XCTAssertEqualObjects(secondEventBody, expectedSecondEventBody); + XCTAssertEqualObjects(secondEventFormattedBody, expectedSecondEventFormattedBody); + XCTAssertEqualObjects(secondEventRelatesToEventId, firstEventId); + XCTAssertEqualObjects(secondWiredEventRelatesToEventId, firstEventId); + } + else if (messageCount == 2) + { + __block MXEvent *localEchoEvent = nil; + + // Reply to second message, which was also a reply + [roomFromBobPOV sendReplyToEvent:event withTextMessage:thirdMessageReplyToSecond formattedTextMessage:thirdMessageFormattedReplyToSecond stringLocalizations:defaultStringLocalizations localEcho:&localEchoEvent success:^(NSString *eventId) { + NSLog(@"Send reply to second message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + + XCTAssertNotNil(localEchoEvent); + + NSString *secondEventId = event.eventId; + NSString *secondEventSender = event.sender; + + NSString *thirdEventBody = localEchoEvent.content[@"body"]; + NSString *thirdEventFormattedBody = localEchoEvent.content[@"formatted_body"]; + NSString *thirdEventRelatesToEventId = localEchoEvent.content[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + NSString *thirdWiredEventRelatesToEventId = localEchoEvent.wireContent[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + NSString *permalinkToUser = [MXTools permalinkToUserWithUserId:secondEventSender]; + NSString *permalinkToEvent = [MXTools permalinkToEvent:secondEventId inRoom:roomId]; + + NSString *expectedThirdEventBody = [NSString stringWithFormat:expectedThirdEventBodyStringFormat, secondEventSender]; + NSString *expectedThirdEventFormattedBody = [NSString stringWithFormat:expectedThirdEventFormattedBodyStringFormat, permalinkToEvent, permalinkToUser, secondEventSender]; + + + XCTAssertEqualObjects(thirdEventBody, expectedThirdEventBody); + XCTAssertEqualObjects(thirdEventFormattedBody, expectedThirdEventFormattedBody); + XCTAssertEqualObjects(thirdEventRelatesToEventId, secondEventId); + XCTAssertEqualObjects(thirdWiredEventRelatesToEventId, secondEventId); + } + else + { + testExpectationFullfillIfComplete(); + } + }]; + + __block NSString *firstEventId; + __block NSString *secondEventId; + + // Listen to messages from Alice POV + [roomFromAlicePOV.liveTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomMessage] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + messageCountFromAlice++; + + if (messageCountFromAlice == 1) + { + firstEventId = event.eventId; + } + else if (messageCountFromAlice == 2) + { + secondEventId = event.eventId; + NSString *secondWiredEventRelatesToEventId = event.wireContent[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + XCTAssertEqualObjects(secondWiredEventRelatesToEventId, firstEventId); + } + else + { + NSString *thirdWiredEventRelatesToEventId = event.wireContent[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + XCTAssertEqualObjects(thirdWiredEventRelatesToEventId, secondEventId); + + testExpectationFullfillIfComplete(); + } + }]; + + // Send first message + [roomFromBobPOV sendTextMessage:firstMessage formattedText:firstFormattedMessage localEcho:nil success:^(NSString *eventId) { + NSLog(@"Send first message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + }]; +} + #pragma mark - Edge cases diff --git a/MatrixSDKTests/MXRoomTests.m b/MatrixSDKTests/MXRoomTests.m index d9fc35b8db..3e15621de6 100644 --- a/MatrixSDKTests/MXRoomTests.m +++ b/MatrixSDKTests/MXRoomTests.m @@ -20,6 +20,8 @@ #import "MatrixSDKTestsData.h" #import "MXSession.h" +#import "MXTools.h" +#import "MXSendReplyEventDefaultStringLocalizations.h" // Do not bother with retain cycles warnings in tests #pragma clang diagnostic push @@ -534,6 +536,113 @@ - (void)tesRoomDirectoryVisibilityLive }]; } +- (void)testSendReplyToTextMessage +{ + NSString *firstMessage = @"**First message!**"; + NSString *firstFormattedMessage = @"

First message!

"; + + NSString *secondMessageReplyToFirst = @"**Reply to first message**"; + NSString *secondMessageFormattedReplyToFirst = @"

Reply to first message

"; + + NSString *expectedSecondEventBodyStringFormat = @"> <%@> **First message!**\n\n**Reply to first message**"; + NSString *expectedSecondEventFormattedBodyStringFormat = @"
In reply to %@

First message!

Reply to first message

"; + + NSString *thirdMessageReplyToSecond = @"**Reply to second message**"; + NSString *thirdMessageFormattedReplyToSecond = @"

Reply to second message

"; + + NSString *expectedThirdEventBodyStringFormat = @"> <%@> **Reply to first message**\n\n**Reply to second message**"; + NSString *expectedThirdEventFormattedBodyStringFormat = @"
In reply to %@

Reply to first message

Reply to second message

"; + + MXSendReplyEventDefaultStringLocalizations *defaultStringLocalizations = [MXSendReplyEventDefaultStringLocalizations new]; + + [matrixSDKTestsData doMXSessionTestWithBobAndARoomWithMessages:self readyToTest:^(MXSession *mxSession, MXRoom *room, XCTestExpectation *expectation) { + + __block NSUInteger messageCount = 0; + + // Listen to messages + [room.liveTimeline listenToEventsOfTypes:@[kMXEventTypeStringRoomMessage] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) { + messageCount++; + + if (messageCount == 1) + { + __block MXEvent *localEchoEvent = nil; + + // Reply to first message + [room sendReplyToEvent:event withTextMessage:secondMessageReplyToFirst formattedTextMessage:secondMessageFormattedReplyToFirst stringLocalizations:defaultStringLocalizations localEcho:&localEchoEvent success:^(NSString *eventId) { + NSLog(@"Send reply to first message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + + XCTAssertNotNil(localEchoEvent); + + NSString *roomId = room.roomId; + NSString *firstEventId = event.eventId; + NSString *firstEventSender = event.sender; + + NSString *secondEventBody = localEchoEvent.content[@"body"]; + NSString *secondEventFormattedBody = localEchoEvent.content[@"formatted_body"]; + NSString *secondEventRelatesToEventId = localEchoEvent.content[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + NSString *permalinkToUser = [MXTools permalinkToUserWithUserId:firstEventSender]; + NSString *permalinkToEvent = [MXTools permalinkToEvent:firstEventId inRoom:roomId]; + + NSString *expectedSecondEventBody = [NSString stringWithFormat:expectedSecondEventBodyStringFormat, firstEventSender]; + NSString *expectedSecondEventFormattedBody = [NSString stringWithFormat:expectedSecondEventFormattedBodyStringFormat, permalinkToEvent, permalinkToUser, firstEventSender]; + + XCTAssertEqualObjects(secondEventBody, expectedSecondEventBody); + XCTAssertEqualObjects(secondEventFormattedBody, expectedSecondEventFormattedBody); + XCTAssertEqualObjects(firstEventId, secondEventRelatesToEventId); + } + else if (messageCount == 2) + { + __block MXEvent *localEchoEvent = nil; + + // Reply to second message, which was also a reply + [room sendReplyToEvent:event withTextMessage:thirdMessageReplyToSecond formattedTextMessage:thirdMessageFormattedReplyToSecond stringLocalizations:defaultStringLocalizations localEcho:&localEchoEvent success:^(NSString *eventId) { + NSLog(@"Send reply to second message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + + XCTAssertNotNil(localEchoEvent); + + NSString *roomId = room.roomId; + NSString *secondEventId = event.eventId; + NSString *secondEventSender = event.sender; + + NSString *thirdEventBody = localEchoEvent.content[@"body"]; + NSString *thirdEventFormattedBody = localEchoEvent.content[@"formatted_body"]; + NSString *thirdEventRelatesToEventId = localEchoEvent.content[@"m.relates_to"][@"m.in_reply_to"][@"event_id"]; + + NSString *permalinkToUser = [MXTools permalinkToUserWithUserId:secondEventSender]; + NSString *permalinkToEvent = [MXTools permalinkToEvent:secondEventId inRoom:roomId]; + + NSString *expectedThirdEventBody = [NSString stringWithFormat:expectedThirdEventBodyStringFormat, secondEventSender]; + NSString *expectedThirdEventFormattedBody = [NSString stringWithFormat:expectedThirdEventFormattedBodyStringFormat, permalinkToEvent, permalinkToUser, secondEventSender]; + + XCTAssertEqualObjects(thirdEventBody, expectedThirdEventBody); + XCTAssertEqualObjects(thirdEventFormattedBody, expectedThirdEventFormattedBody); + XCTAssertEqualObjects(secondEventId, thirdEventRelatesToEventId); + } + else + { + [expectation fulfill]; + } + }]; + + // Send first message + [room sendTextMessage:firstMessage formattedText:firstFormattedMessage localEcho:nil success:^(NSString *eventId) { + NSLog(@"Send first message with success"); + } failure:^(NSError *error) { + XCTFail(@"The request should not fail - NSError: %@", error); + [expectation fulfill]; + }]; + }]; +} + @end #pragma clang diagnostic pop