diff --git a/.swiftlint.yml b/.swiftlint.yml index 39f486b81..fed6b567d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -13,6 +13,7 @@ disabled_rules: - file_length - function_body_length - line_length + - file_types_order empty_count: severity: warning diff --git a/NextcloudTalk/Chat/NCChatController.h b/NextcloudTalk/Chat/NCChatController.h deleted file mode 100644 index 7838e51e0..000000000 --- a/NextcloudTalk/Chat/NCChatController.h +++ /dev/null @@ -1,60 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import - -#import "NCChatMessage.h" - -@class OcsError; -@class NCRoom; - -typedef void (^UpdateHistoryInBackgroundCompletionBlock)(OcsError *error); -typedef void (^GetMessagesContextCompletionBlock)(NSArray * _Nullable messages); -typedef void (^GetSingleMessageCompletionBlock)(NCChatMessage* _Nullable message); - -extern NSString * const NCChatControllerDidReceiveInitialChatHistoryNotification; -extern NSString * const NCChatControllerDidReceiveInitialChatHistoryOfflineNotification;; -extern NSString * const NCChatControllerDidReceiveChatHistoryNotification; -extern NSString * const NCChatControllerDidReceiveChatMessagesNotification; -extern NSString * const NCChatControllerDidSendChatMessageNotification; -extern NSString * const NCChatControllerDidReceiveChatBlockedNotification; -extern NSString * const NCChatControllerDidReceiveNewerCommonReadMessageNotification; -extern NSString * const NCChatControllerDidReceiveUpdateMessageNotification; -extern NSString * const NCChatControllerDidReceiveHistoryClearedNotification; -extern NSString * const NCChatControllerDidReceiveCallStartedMessageNotification; -extern NSString * const NCChatControllerDidReceiveCallEndedMessageNotification; -extern NSString * const NCChatControllerDidReceiveMessagesInBackgroundNotification; -extern NSString * const NCChatControllerDidReceiveThreadMessageNotification; -extern NSString * const NCChatControllerDidReceiveThreadNotFoundNotification; - -@interface NCChatController : NSObject - -@property (nonatomic, strong) NCRoom *room; -@property (nonatomic, assign) NSInteger threadId; -@property (nonatomic, assign) BOOL hasReceivedMessagesFromServer; - -- (instancetype)initForRoom:(NCRoom *)room; -- (instancetype)initForThreadId:(NSInteger)threadId inRoom:(NCRoom *)room; -- (void)sendChatMessage:(NSString *)message replyTo:(NSInteger)replyTo referenceId:(NSString *)referenceId silently:(BOOL)silently; -- (void)sendChatMessage:(NCChatMessage *)message; -- (NSArray * _Nonnull)getTemporaryMessages; -- (void)getInitialChatHistory; -- (void)getInitialChatHistoryForOfflineMode; -- (void)getHistoryBatchFromMessagesId:(NSInteger)messageId; -- (void)getHistoryBatchOfflineFromMessagesId:(NSInteger)messageId; -- (BOOL)hasOlderStoredMessagesThanMessageId:(NSInteger)messageId; -- (void)checkForNewMessagesFromMessageId:(NSInteger)messageId; -- (void)updateHistoryInBackgroundWithCompletionBlock:(UpdateHistoryInBackgroundCompletionBlock)block; -- (void)startReceivingNewChatMessages; -- (void)stopReceivingNewChatMessages; -- (void)stopChatController; -- (void)clearHistoryAndResetChatController; -- (void)removeExpiredMessages; -- (BOOL)hasHistoryFromMessageId:(NSInteger)messageId; -- (void)storeMessages:(NSArray *)messages withRealm:(RLMRealm *)realm; -- (void)getMessageContextForMessageId:(NSInteger)messageId withLimit:(NSInteger)limit withCompletionBlock:(GetMessagesContextCompletionBlock)block; -- (void)getSingleMessageWithMessageId:(NSInteger)messageId withCompletionBlock:(_Nonnull GetSingleMessageCompletionBlock)block; - -@end diff --git a/NextcloudTalk/Chat/NCChatController.m b/NextcloudTalk/Chat/NCChatController.m deleted file mode 100644 index 22c49a70c..000000000 --- a/NextcloudTalk/Chat/NCChatController.m +++ /dev/null @@ -1,1188 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#import "NCChatController.h" - -#import "NCChatBlock.h" -#import "NCDatabaseManager.h" -#import "NCIntentController.h" - -#import "NextcloudTalk-Swift.h" - -NSString * const NCChatControllerDidReceiveInitialChatHistoryNotification = @"NCChatControllerDidReceiveInitialChatHistoryNotification"; -NSString * const NCChatControllerDidReceiveInitialChatHistoryOfflineNotification = @"NCChatControllerDidReceiveInitialChatHistoryOfflineNotification"; -NSString * const NCChatControllerDidReceiveChatHistoryNotification = @"NCChatControllerDidReceiveChatHistoryNotification"; -NSString * const NCChatControllerDidReceiveChatMessagesNotification = @"NCChatControllerDidReceiveChatMessagesNotification"; -NSString * const NCChatControllerDidSendChatMessageNotification = @"NCChatControllerDidSendChatMessageNotification"; -NSString * const NCChatControllerDidReceiveChatBlockedNotification = @"NCChatControllerDidReceiveChatBlockedNotification"; -NSString * const NCChatControllerDidReceiveNewerCommonReadMessageNotification = @"NCChatControllerDidReceiveNewerCommonReadMessageNotification"; -NSString * const NCChatControllerDidReceiveUpdateMessageNotification = @"NCChatControllerDidReceiveUpdateMessageNotification"; -NSString * const NCChatControllerDidReceiveHistoryClearedNotification = @"NCChatControllerDidReceiveHistoryClearedNotification"; -NSString * const NCChatControllerDidReceiveCallStartedMessageNotification = @"NCChatControllerDidReceiveCallStartedMessageNotification"; -NSString * const NCChatControllerDidReceiveCallEndedMessageNotification = @"NCChatControllerDidReceiveCallEndedMessageNotification"; -NSString * const NCChatControllerDidReceiveMessagesInBackgroundNotification = @"NCChatControllerDidReceiveMessagesInBackgroundNotification"; -NSString * const NCChatControllerDidReceiveThreadMessageNotification = @"NCChatControllerDidReceiveThreadMessageNotification"; -NSString * const NCChatControllerDidReceiveThreadNotFoundNotification = @"NCChatControllerDidReceiveThreadNotFoundNotification"; - -@interface NCChatController () - -@property (nonatomic, assign) BOOL stopChatMessagesPoll; -@property (nonatomic, strong) TalkAccount *account; -@property (nonatomic, strong) NSURLSessionTask *getHistoryTask; -@property (nonatomic, strong) NSURLSessionTask *pullMessagesTask; - -@end - -@implementation NCChatController - -- (instancetype)initForRoom:(NCRoom *)room -{ - self = [super init]; - if (self) { - _room = room; - _account = [[NCDatabaseManager sharedInstance] talkAccountForAccountId:_room.accountId]; - - [[AllocationTracker shared] addAllocation:@"NCChatController"]; - } - - return self; -} - -- (instancetype)initForThreadId:(NSInteger)threadId inRoom:(NCRoom *)room -{ - self = [super init]; - if (self) { - _room = room; - _threadId = threadId; - _account = [[NCDatabaseManager sharedInstance] talkAccountForAccountId:_room.accountId]; - - [[AllocationTracker shared] addAllocation:@"NCChatController"]; - } - - return self; -} - -- (void)dealloc -{ - [[AllocationTracker shared] removeAllocation:@"NCChatController"]; -} - -- (BOOL)isThreadController -{ - return _threadId > 0; -} - -- (BOOL)willBeVisibleMessage:(NCChatMessage *)message -{ - // Update messages are not visible in normal chats or thread views - if ([message isUpdateMessage]) { - return NO; - } - - // Thread messages are not visible in normal chat views. - if (![self isThreadController] && [message isThreadMessage]) { - return NO; - } - - // In thread controller mode we only receive thread messages, - // so no check for non-thread messages is needed - - return YES; -} - -#pragma mark - Database - -- (RLMResults *)managedSortedBlocksForRoomOrThread -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"internalId = %@ AND threadId = 0", _room.internalId]; - if ([self isThreadController]) { - predicate = [NSPredicate predicateWithFormat:@"internalId = %@ AND threadId = %ld", _room.internalId, (long)_threadId]; - } - RLMResults *managedBlocks = [NCChatBlock objectsWithPredicate:predicate]; - return [managedBlocks sortedResultsUsingKeyPath:@"newestMessageId" ascending:YES]; -} - -- (NSArray *)chatBlocksForRoomOrThread -{ - RLMResults *managedSortedBlocks = [self managedSortedBlocksForRoomOrThread]; - // Create an unmanaged copy of the blocks - NSMutableArray *sortedBlocks = [NSMutableArray new]; - for (NCChatBlock *managedBlock in managedSortedBlocks) { - NCChatBlock *sortedBlock = [[NCChatBlock alloc] initWithValue:managedBlock]; - [sortedBlocks addObject:sortedBlock]; - } - - return sortedBlocks; -} - -- (NSArray *)getBatchOfMessagesInBlock:(NCChatBlock *)chatBlock fromMessageId:(NSInteger)messageId included:(BOOL)included ensureIncludesMessageId:(NSInteger)ensuredMessageId -{ - NSInteger fromMessageId = messageId > 0 ? messageId : chatBlock.newestMessageId; - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND messageId >= %ld AND messageId < %ld", _account.accountId, _room.token, (long)chatBlock.oldestMessageId, (long)fromMessageId]; - if (included) { - query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND messageId >= %ld AND messageId <= %ld", _account.accountId, _room.token, (long)chatBlock.oldestMessageId, (long)fromMessageId]; - } - - // Thread - if ([self isThreadController]) { - query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND threadId = %ld AND messageId >= %ld AND messageId < %ld", _account.accountId, _room.token, _threadId, (long)chatBlock.oldestMessageId, (long)fromMessageId]; - if (included) { - query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND threadId = %ld AND messageId >= %ld AND messageId <= %ld", _account.accountId, _room.token, _threadId, (long)chatBlock.oldestMessageId, (long)fromMessageId]; - } - } - - RLMResults *managedMessages = [NCChatMessage objectsWithPredicate:query]; - RLMResults *managedSortedMessages = [managedMessages sortedResultsUsingKeyPath:@"messageId" ascending:YES]; - // Create an unmanaged copy of the messages - NSMutableArray *sortedMessages = [NSMutableArray new]; - NSInteger numberOfStoredVisibleMessages = 0; - BOOL reachedEnsuredMessageId = false; - - if (ensuredMessageId <= 0) { - // When there's no unreadMessageId we need to ensure being included, we just assume it's included to enforce the default limit - reachedEnsuredMessageId = true; - } - - // Iterate backwards and check if we gathered 100 visible messages (or more, if we need to include the unread marker) - for (NSInteger i = (managedSortedMessages.count - 1); i >= 0; i--) { - NCChatMessage *sortedMessage = [[NCChatMessage alloc] initWithValue:managedSortedMessages[i]]; - - // Since we iterate backwords, insert the object at the beginning of the array to keep it sorted - [sortedMessages insertObject:sortedMessage atIndex:0]; - - if (sortedMessage.messageId == ensuredMessageId) { - reachedEnsuredMessageId = true; - } - - // We only count visible messages and we only count, if we already found the message that we need to ensure - if (reachedEnsuredMessageId && [self willBeVisibleMessage:sortedMessage]) { - numberOfStoredVisibleMessages += 1; - } - - // Break in case we found the ensured message and we hit the visible message limit - if (reachedEnsuredMessageId && numberOfStoredVisibleMessages >= NCAPIController.shared.kReceivedChatMessagesLimit) { - break; - } - } - - NSLog(@"Returning batch of %ld messages", [sortedMessages count]); - - return sortedMessages; -} - -- (NSArray *)getNewStoredMessagesInBlock:(NCChatBlock *)chatBlock sinceMessageId:(NSInteger)messageId -{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND messageId > %ld AND messageId <= %ld AND (isThread == 0 OR threadId == 0 OR threadId == messageId)", _account.accountId, _room.token, (long)messageId, (long)chatBlock.newestMessageId]; - - if ([self isThreadController]) { - query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND threadId = %ld AND messageId > %ld AND messageId <= %ld", _account.accountId, _room.token, _threadId, (long)messageId, (long)chatBlock.newestMessageId]; - } - - RLMResults *managedMessages = [NCChatMessage objectsWithPredicate:query]; - RLMResults *managedSortedMessages = [managedMessages sortedResultsUsingKeyPath:@"messageId" ascending:YES]; - // Create an unmanaged copy of the messages - NSMutableArray *sortedMessages = [NSMutableArray new]; - for (NCChatMessage *managedMessage in managedSortedMessages) { - NCChatMessage *sortedMessage = [[NCChatMessage alloc] initWithValue:managedMessage]; - [sortedMessages addObject:sortedMessage]; - } - - return sortedMessages; -} - -- (void)storeMessages:(NSArray *)messages withRealm:(RLMRealm *)realm { - // Add or update messages - for (NSDictionary *messageDict in messages) { - // messageWithDictionary takes care of setting a potential available parentId - NCChatMessage *message = [NCChatMessage messageWithDictionary:messageDict andAccountId:_account.accountId]; - - if (message.referenceId && ![message.referenceId isEqualToString:@""]) { - NCChatMessage *managedTemporaryMessage = [NCChatMessage objectsWhere:@"referenceId = %@ AND isTemporary = true", message.referenceId].firstObject; - if (managedTemporaryMessage) { - [realm deleteObject:managedTemporaryMessage]; - } - } - - NCChatMessage *managedMessage = [NCChatMessage objectsWhere:@"internalId = %@", message.internalId].firstObject; - if (managedMessage) { - [NCChatMessage updateChatMessage:managedMessage withChatMessage:message isRoomLastMessage:NO]; - } else if (message) { - [realm addObject:message]; - } - - if (message.isThreadCreatedMessage) { - NCThread *thread = [NCThread createThreadFromMessage:message andAccountId:message.accountId]; - - if (thread) { - [realm addObject:thread]; - } - } else if (message.isThreadMessage) { - [NCThread updateThreadWithThreadMessage:message]; - } - - NCChatMessage *parent = [NCChatMessage messageWithDictionary:[messageDict objectForKey:@"parent"] andAccountId:_account.accountId]; - NCChatMessage *managedParentMessage = [NCChatMessage objectsWhere:@"internalId = %@", parent.internalId].firstObject; - if (managedParentMessage) { - // updateChatMessage takes care of not setting a parentId to nil if there was one before - [NCChatMessage updateChatMessage:managedParentMessage withChatMessage:parent isRoomLastMessage:NO]; - } else if (parent) { - [realm addObject:parent]; - } - } -} - -- (void)storeMessages:(NSArray *)messages -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - [self storeMessages:messages withRealm:realm]; - }]; -} - -- (BOOL)hasOlderStoredMessagesThanMessageId:(NSInteger)messageId -{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND messageId < %ld", _account.accountId, _room.token, (long)messageId]; - return [NCChatMessage objectsWithPredicate:query].count > 0; -} - -- (void)removeAllStoredMessagesAndChatBlocks -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@", _account.accountId, _room.token]; - [realm deleteObjects:[NCChatMessage objectsWithPredicate:query]]; - [realm deleteObjects:[NCChatBlock objectsWithPredicate:query]]; - NSPredicate *threadsQuery = [NSPredicate predicateWithFormat:@"accountId = %@ AND roomToken = %@", _account.accountId, _room.token]; - [realm deleteObjects:[NCThread objectsWithPredicate:threadsQuery]]; - }]; -} - -- (void)removeExpiredMessages -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - NSInteger currentTimestamp = [[NSDate date] timeIntervalSince1970]; - [realm transactionWithBlock:^{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND expirationTimestamp > 0 AND expirationTimestamp <= %ld", _account.accountId, _room.token, currentTimestamp]; - [realm deleteObjects:[NCChatMessage objectsWithPredicate:query]]; - }]; -} - -- (void)updateLastChatBlockWithNewestKnown:(NSInteger)newestKnown -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - RLMResults *managedSortedBlocks = [self managedSortedBlocksForRoomOrThread]; - NCChatBlock *lastBlock = managedSortedBlocks.lastObject; - if (newestKnown > 0) { - lastBlock.newestMessageId = newestKnown; - } - }]; -} - -- (void)updateChatBlocksWithLastKnown:(NSInteger)lastKnown -{ - if (lastKnown <= 0) { - return; - } - - // Safety check: prevent storing a messageId older than the thread's first message as block's oldestMessageId when in a thread controller - NSInteger oldestMessageKnown = [self isThreadController] && lastKnown < _threadId ? _threadId : lastKnown; - - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - RLMResults *managedSortedBlocks = [self managedSortedBlocksForRoomOrThread]; - NCChatBlock *lastBlock = managedSortedBlocks.lastObject; - // There is more than one chat block stored - if (managedSortedBlocks.count > 1) { - for (NSInteger i = managedSortedBlocks.count - 2; i >= 0; i--) { - NCChatBlock *block = managedSortedBlocks[i]; - // Merge blocks if the lastKnown message is inside the current block - if (lastKnown >= block.oldestMessageId && lastKnown <= block.newestMessageId) { - lastBlock.oldestMessageId = block.oldestMessageId; - [realm deleteObject:block]; - break; - // Update lastBlock if the lastKnown message is between the 2 blocks - } else if (lastKnown > block.newestMessageId) { - lastBlock.oldestMessageId = oldestMessageKnown; - break; - // The current block is completely included in the retrieved history - // This could happen if we vary the message limit when fetching messages - // Delete included block - } else if (lastKnown < block.oldestMessageId) { - [realm deleteObject:block]; - } - } - // There is just one chat block stored - } else { - lastBlock.oldestMessageId = oldestMessageKnown; - } - }]; -} - -- (void)updateChatBlocksWithReceivedMessages:(NSArray *)messages newestKnown:(NSInteger)newestKnown andLastKnown:(NSInteger)lastKnown -{ - NSArray *sortedMessages = [self sortedMessagesFromMessageArray:messages]; - NCChatMessage *newestMessageReceived = sortedMessages.lastObject; - NSInteger newestMessageKnown = newestKnown > 0 ? newestKnown : newestMessageReceived.messageId; - // Safety check: prevent storing a messageId older than the thread's first message as block's oldestMessageId when in a thread controller - NSInteger oldestMessageKnown = [self isThreadController] && lastKnown < _threadId ? _threadId : lastKnown; - - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - RLMResults *managedSortedBlocks = [self managedSortedBlocksForRoomOrThread]; - - // Create new chat block - NCChatBlock *newBlock = [[NCChatBlock alloc] init]; - newBlock.internalId = _room.internalId; - newBlock.accountId = _room.accountId; - newBlock.token = _room.token; - newBlock.threadId = _threadId; - newBlock.oldestMessageId = oldestMessageKnown; - newBlock.newestMessageId = newestMessageKnown; - newBlock.hasHistory = YES; - - // There is at least one chat block stored - if (managedSortedBlocks.count > 0) { - for (NSInteger i = managedSortedBlocks.count - 1; i >= 0; i--) { - NCChatBlock *block = managedSortedBlocks[i]; - // Merge blocks if the lastKnown message is inside the current block - if (lastKnown >= block.oldestMessageId && lastKnown <= block.newestMessageId) { - block.newestMessageId = newestMessageKnown; - break; - // Add new block if it didn't reach the previous block - } else if (lastKnown > block.newestMessageId) { - [realm addObject:newBlock]; - break; - // The current block is completely included in the retrieved history - // This could happen if we vary the message limit when fetching messages - // Delete included block - } else if (lastKnown < block.oldestMessageId) { - [realm deleteObject:block]; - } - } - // No chat blocks stored yet, add new chat block - } else { - [realm addObject:newBlock]; - } - }]; -} - -- (void)updateHistoryFlagInFirstBlock -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - RLMResults *managedSortedBlocks = [self managedSortedBlocksForRoomOrThread]; - NCChatBlock *firstChatBlock = managedSortedBlocks.firstObject; - firstChatBlock.hasHistory = NO; - }]; -} - -- (void)transactionForMessageWithReferenceId:(NSString *)referenceId withBlock:(void(^)(NCChatMessage *message))block -{ - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - NCChatMessage *managedChatMessage = [NCChatMessage objectsWhere:@"referenceId = %@ AND isTemporary = true", referenceId].firstObject; - block(managedChatMessage); - }]; -} - -- (NSArray *)sortedMessagesFromMessageArray:(NSArray *)messages -{ - NSMutableArray *sortedMessages = [[NSMutableArray alloc] initWithCapacity:messages.count]; - for (NSDictionary *messageDict in messages) { - NCChatMessage *message = [NCChatMessage messageWithDictionary:messageDict]; - [sortedMessages addObject:message]; - } - // Sort by messageId - NSSortDescriptor *valueDescriptor = [[NSSortDescriptor alloc] initWithKey:@"messageId" ascending:YES]; - NSArray *descriptors = [NSArray arrayWithObject:valueDescriptor]; - [sortedMessages sortUsingDescriptors:descriptors]; - - return sortedMessages; -} - -#pragma mark - Chat - -- (NSArray * _Nonnull)getTemporaryMessages -{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND isTemporary = true", _account.accountId, _room.token]; - RLMResults *managedTemporaryMessages = [NCChatMessage objectsWithPredicate:query]; - RLMResults *managedSortedTemporaryMessages = [managedTemporaryMessages sortedResultsUsingKeyPath:@"timestamp" ascending:YES]; - - // Mark temporary messages sent more than 12 hours ago as failed-to-send messages - NSInteger twelveHoursAgoTimestamp = [[NSDate date] timeIntervalSince1970] - (60 * 60 * 12); - - for (NCChatMessage *temporaryMessage in managedTemporaryMessages) { - if (temporaryMessage.timestamp < twelveHoursAgoTimestamp) { - RLMRealm *realm = [RLMRealm defaultRealm]; - [realm transactionWithBlock:^{ - temporaryMessage.isOfflineMessage = NO; - temporaryMessage.sendingFailed = YES; - }]; - } - } - - // Create an unmanaged copy of the messages - NSMutableArray *sortedMessages = [NSMutableArray new]; - for (NCChatMessage *managedMessage in managedSortedTemporaryMessages) { - NCChatMessage *sortedMessage = [[NCChatMessage alloc] initWithValue:managedMessage]; - [sortedMessages addObject:sortedMessage]; - } - - return sortedMessages; -} - -- (void)updateHistoryInBackgroundWithCompletionBlock:(UpdateHistoryInBackgroundCompletionBlock)block -{ - // If there's a pull task running right now, we should not interfere with that - if (_pullMessagesTask && _pullMessagesTask.state == NSURLSessionTaskStateRunning) { - if (block) { - NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain code:0 userInfo:nil]; - block(error); - } - - return; - } - - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - __block BOOL expired = NO; - - BGTaskHelper *bgTask = [BGTaskHelper startBackgroundTaskWithName:@"updateHistoryInBackgroundWithCompletionBlock" expirationHandler:^(BGTaskHelper *task) { - [NCLog log:@"ExpirationHandler called updateHistoryInBackgroundWithCompletionBlock"]; - expired = YES; - - // Make sure we actually end a running pullMessagesTask, because otherwise the completion handler might not be called in time - [self->_pullMessagesTask cancel]; - }]; - - _pullMessagesTask = [[NCAPIController sharedInstance] receiveChatMessagesOfRoom:_room.token fromLastMessageId:lastChatBlock.newestMessageId inThread:_threadId history:NO includeLastMessage:NO timeout:NO limit:NCAPIController.shared.kReceivedChatMessagesLimit lastCommonReadMessage:_room.lastCommonReadMessage setReadMarker:NO markNotificationsAsRead:NO forAccount:_account completionBlock:^(NSArray *messages, NSInteger lastKnownMessage, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode) { - if (expired) { - if (block) { - block(error); - } - - [bgTask stopBackgroundTask]; - - return; - } - - if (error) { - NSLog(@"Could not get background chat history. Error: %@", error.description); - } else { - // Update chat blocks - [self updateLastChatBlockWithNewestKnown:lastKnownMessage]; - - // Store new messages - if (messages.count > 0) { - // In case we finish after the app already got active again, notify any potential view controller - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:self->_room.token forKey:@"room"]; - - for (NSDictionary *messageDict in messages) { - NCChatMessage *message = [NCChatMessage messageWithDictionary:messageDict andAccountId:self->_account.accountId]; - - if (message && [message.systemMessage isEqualToString:@"history_cleared"]) { - [self clearHistoryAndResetChatController]; - - [userInfo setObject:message forKey:@"historyCleared"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveHistoryClearedNotification - object:self - userInfo:userInfo]; - return; - } - } - - [self storeMessages:messages]; - [self checkLastCommonReadMessage:lastCommonReadMessage]; - - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveMessagesInBackgroundNotification - object:self - userInfo:userInfo]; - } - } - - if (block) { - block(error); - } - - [bgTask stopBackgroundTask]; - }]; -} - -- (void)checkForNewMessagesFromMessageId:(NSInteger)messageId -{ - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - NSArray *storedMessages = [self getNewStoredMessagesInBlock:lastChatBlock sinceMessageId:messageId]; - - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:self->_room.token forKey:@"room"]; - - if (storedMessages.count > 0) { - for (NCChatMessage *message in storedMessages) { - // Notify if "call started" have been received - if ([message.systemMessage isEqualToString:@"call_started"]) { - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveCallStartedMessageNotification - object:self - userInfo:userInfo]; - } - // Notify if "call eneded" have been received - if ([message.systemMessage isEqualToString:@"call_ended"] || - [message.systemMessage isEqualToString:@"call_ended_everyone"] || - [message.systemMessage isEqualToString:@"call_missed"] || - [message.systemMessage isEqualToString:@"call_tried"]) { - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveCallEndedMessageNotification - object:self - userInfo:userInfo]; - } - // Notify if an "update messages" have been received - if ([message isUpdateMessage] || [message isVisibleUpdateMessage]) { - [userInfo setObject:message forKey:@"updateMessage"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveUpdateMessageNotification - object:self - userInfo:userInfo]; - } - // Notify if a "thread messages" have been received - if ([message isThreadMessage]) { - [userInfo setObject:message forKey:@"threadMessage"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveThreadMessageNotification - object:self - userInfo:userInfo]; - } - // Notify if "history cleared" has been received - if ([message.systemMessage isEqualToString:@"history_cleared"]) { - [userInfo setObject:message forKey:@"historyCleared"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveHistoryClearedNotification - object:self - userInfo:userInfo]; - return; - } - } - - [userInfo setObject:storedMessages forKey:@"messages"]; - [userInfo setObject:@(!_hasReceivedMessagesFromServer) forKey:@"firstNewMessagesAfterHistory"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveChatMessagesNotification - object:self - userInfo:userInfo]; - - [self updateLastMessageIfNeededFromMessages:storedMessages]; - } -} - -- (void)updateLastMessageIfNeededFromMessages:(NSArray *)storedMessages -{ - // Try to find the last non-update message - Messages are already sorted by messageId here - NCChatMessage *lastNonUpdateMessage; - NCChatMessage *lastMessage = [storedMessages lastObject]; - NCChatMessage *tempMessage; - - for (NSInteger i = (storedMessages.count - 1); i >= 0; i--) { - tempMessage = [storedMessages objectAtIndex:i]; - - if (![tempMessage isUpdateMessage]) { - lastNonUpdateMessage = tempMessage; - break; - } - } - - // Make sure we update the unread flags for the room (lastMessage can already be set, but there still might be unread flags) - if (lastMessage && lastMessage.timestamp >= self->_room.lastActivity) { - // Make sure our local reference to the room also has the correct lastActivity set - if (lastNonUpdateMessage) { - self->_room.lastActivity = lastNonUpdateMessage.timestamp; - } - - // We always want to set the room to have no unread messages, optionally we also want to update the last message, if there's one - [[NCRoomsManager shared] setNoUnreadMessagesForRoom:self->_room withLastMessage:lastNonUpdateMessage]; - } -} - -- (void)getInitialChatHistoryForOfflineMode -{ - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:_room.token forKey:@"room"]; - - NSInteger lastReadMessageId = 0; - if ([[NCDatabaseManager sharedInstance] roomHasTalkCapability:kCapabilityChatReadMarker forRoom:self.room]) { - lastReadMessageId = _room.lastReadMessage; - } - - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - NSArray *storedMessages = [self getBatchOfMessagesInBlock:lastChatBlock fromMessageId:lastChatBlock.newestMessageId included:YES ensureIncludesMessageId:lastReadMessageId]; - [userInfo setObject:storedMessages forKey:@"messages"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveInitialChatHistoryOfflineNotification - object:self - userInfo:userInfo]; -} - -- (void)getInitialChatHistory -{ - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:_room.token forKey:@"room"]; - - // Clear expired messages - [self removeExpiredMessages]; - - NSInteger lastReadMessageId = 0; - // If the chat supports read markers and this is not a thread controller, start from the room's last read message. - // In thread controllers, always start from the latest message (lastReadMessageId = 0) because the room's last read message - // might be outdated and older than the thread's first message, which would lead to a 304 response. - if ([[NCDatabaseManager sharedInstance] roomHasTalkCapability:kCapabilityChatReadMarker forRoom:self.room] && ![self isThreadController]) { - lastReadMessageId = _room.lastReadMessage; - } - - [self fetchHistoryUntilVisibleFromMessageId:lastReadMessageId forInitialChatHistory:YES isFirstIteration:YES completion:^(NSArray *messages, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode) { - if (error) { - if ([self isChatBeingBlocked:statusCode]) { - [self notifyChatIsBlocked]; - return; - } - [userInfo setObject:error forKey:@"error"]; - NSLog(@"Could not get initial chat history. Error: %@", error.description); - } else if (messages.count > 0) { - [userInfo setObject:messages forKey:@"messages"]; - [self updateLastMessageIfNeededFromMessages:messages]; - } - - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveInitialChatHistoryNotification - object:self - userInfo:userInfo]; - - [self checkLastCommonReadMessage:lastCommonReadMessage]; - }]; -} - -- (void)getHistoryBatchFromMessagesId:(NSInteger)messageId -{ - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:_room.token forKey:@"room"]; - - [self fetchHistoryUntilVisibleFromMessageId:messageId forInitialChatHistory:NO isFirstIteration:YES completion:^(NSArray *messages, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode) { - if (statusCode == 304) { - [self updateHistoryFlagInFirstBlock]; - } - if (error) { - if ([self isChatBeingBlocked:statusCode]) { - [self notifyChatIsBlocked]; - return; - } - [userInfo setObject:error forKey:@"error"]; - if (statusCode != 304) { - NSLog(@"Could not get chat history. Error: %@", error.description); - } - } else if (messages.count > 0) { - [userInfo setObject:messages forKey:@"messages"]; - } - - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveChatHistoryNotification - object:self - userInfo:userInfo]; - }]; -} - - -- (void)fetchHistoryUntilVisibleFromMessageId:(NSInteger)messageId forInitialChatHistory:(BOOL)forInitialChatHistory isFirstIteration:(BOOL)isFirstIteration completion:(void (^)(NSArray *messages, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode))completion -{ - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - - // First, try to load messages from local storage (DB) - if (lastChatBlock) { - BOOL canUseLocalStorage = NO; - - if (forInitialChatHistory) { - // For initial chat history: make sure messageId is inside the last chat block - canUseLocalStorage = (lastChatBlock.newestMessageId > 0 && - messageId >= lastChatBlock.oldestMessageId && - lastChatBlock.newestMessageId >= messageId); - } else { - // For history batch: just make sure messageId is newer than last chat block's oldest message - canUseLocalStorage = (lastChatBlock.newestMessageId > 0 && - messageId >= lastChatBlock.oldestMessageId); - } - - if (canUseLocalStorage) { - // For initial chat history: always get batch from last chat block's newest message, even if it's not the first iteration. - // For history batch: get batch from the passed messageId. If it's not the first iteration, we will just skip invisible messages - // from previous iterations and not pass them to the chat view. - NSArray *storedMessages = [self getBatchOfMessagesInBlock:lastChatBlock - fromMessageId:forInitialChatHistory ? lastChatBlock.newestMessageId : messageId - included:forInitialChatHistory - ensureIncludesMessageId:forInitialChatHistory ? messageId : 0]; - - for (NCChatMessage *message in storedMessages) { - // Since the passed messageId might not be the lowest one, we update it here to ensure we request the missing messages - if (message.messageId < messageId) { - messageId = message.messageId; - } - - // If there is at least one visible message, we can stop fetching messages and pass them - if ([self willBeVisibleMessage:message]) { - completion(storedMessages, 0, nil, 0); - return; - } - } - } - } - - // If no messages are found or visible in last chat block, fall back to fetching them from the server - _getHistoryTask = [[NCAPIController sharedInstance] receiveChatMessagesOfRoom:_room.token - fromLastMessageId:messageId - inThread:_threadId - history:YES - includeLastMessage:forInitialChatHistory - timeout:NO - limit:NCAPIController.shared.kReceivedChatMessagesLimit - lastCommonReadMessage:_room.lastCommonReadMessage - setReadMarker:YES - markNotificationsAsRead:YES - forAccount:_account - completionBlock:^(NSArray *messages, - NSInteger lastKnownMessage, - NSInteger lastCommonReadMessage, - OcsError *error, - NSInteger statusCode) { - if (self->_stopChatMessagesPoll) { - return; - } - - // Error handling - if (error) { - completion(nil, 0, error, statusCode); - return; - } - - // Update chat blocks - // Only store a new block when getting initial history and we are in the first iteration. - // Otherwise, only update the chat blocks with history messages ("backwards"). - if (forInitialChatHistory && isFirstIteration) { - [self updateChatBlocksWithReceivedMessages:messages newestKnown:messageId andLastKnown:lastKnownMessage]; - } else { - [self updateChatBlocksWithLastKnown:lastKnownMessage]; - } - - // Store new messages - if (messages.count > 0) { - [self storeMessages:messages]; - - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - // For initial chat history: always get batch from last chat block's newest message, even if it's not the first iteration. - // For history batch: get batch from the passed messageId. If it's not the first iteration, we will just skip invisible messages - // from previous iterations and not pass them to the chat view. - NSArray *history = [self getBatchOfMessagesInBlock:lastChatBlock - fromMessageId:forInitialChatHistory ? lastChatBlock.newestMessageId : messageId - included:forInitialChatHistory - ensureIncludesMessageId:forInitialChatHistory ? messageId : 0]; - - for (NCChatMessage *message in history) { - if ([self willBeVisibleMessage:message]) { - completion(history, lastCommonReadMessage, nil, 0); - return; - } - } - - // Prevent infinite loop in case there are no new messages - if (statusCode != 304) { - // Recursively fetch messages until finding visible ones - [self fetchHistoryUntilVisibleFromMessageId:lastKnownMessage - forInitialChatHistory:forInitialChatHistory - isFirstIteration:NO - completion:completion]; - return; - } - } - - completion(@[], 0, nil, 0); - }]; -} - -- (void)getHistoryBatchOfflineFromMessagesId:(NSInteger)messageId -{ - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:_room.token forKey:@"room"]; - - NSArray *chatBlocks = [self chatBlocksForRoomOrThread]; - NSMutableArray *historyBatch = [NSMutableArray new]; - if (chatBlocks.count > 0) { - for (NSInteger i = chatBlocks.count - 1; i >= 0; i--) { - NCChatBlock *currentBlock = chatBlocks[i]; - BOOL noMoreMessagesToRetrieveInBlock = NO; - if (currentBlock.oldestMessageId < messageId) { - NSArray *storedMessages = [self getBatchOfMessagesInBlock:currentBlock fromMessageId:messageId included:NO ensureIncludesMessageId:0]; - historyBatch = [[NSMutableArray alloc] initWithArray:storedMessages]; - if (storedMessages.count > 0) { - break; - } else { - // We use this flag in case the rest of the messages in current block - // are system messages invisible for the user. - noMoreMessagesToRetrieveInBlock = YES; - } - } - if (i > 0 && (currentBlock.oldestMessageId == messageId || noMoreMessagesToRetrieveInBlock)) { - NCChatBlock *previousBlock = chatBlocks[i - 1]; - NSArray *storedMessages = [self getBatchOfMessagesInBlock:previousBlock fromMessageId:previousBlock.newestMessageId included:YES ensureIncludesMessageId:0]; - historyBatch = [[NSMutableArray alloc] initWithArray:storedMessages]; - [userInfo setObject:@(YES) forKey:@"shouldAddBlockSeparator"]; - break; - } - } - } - - if (historyBatch.count == 0) { - [userInfo setObject:@(YES) forKey:@"noMoreStoredHistory"]; - } - - [userInfo setObject:historyBatch forKey:@"messages"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveChatHistoryNotification - object:self - userInfo:userInfo]; -} - -- (void)stopReceivingChatHistory -{ - [_getHistoryTask cancel]; -} - -- (void)startReceivingChatMessagesFromMessagesId:(NSInteger)messageId withTimeout:(BOOL)timeout -{ - _stopChatMessagesPoll = NO; - [_pullMessagesTask cancel]; - _pullMessagesTask = [[NCAPIController sharedInstance] receiveChatMessagesOfRoom:_room.token fromLastMessageId:messageId inThread:_threadId history:NO includeLastMessage:NO timeout:timeout limit:NCAPIController.shared.kReceivedChatMessagesLimit lastCommonReadMessage:_room.lastCommonReadMessage setReadMarker:YES markNotificationsAsRead:YES forAccount:_account completionBlock:^(NSArray *messages, NSInteger lastKnownMessage, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode) { - if (self->_stopChatMessagesPoll) { - return; - } - - if (error) { - if ([self isChatBeingBlocked:statusCode]) { - [self notifyChatIsBlocked]; - return; - } - - if (statusCode == 404) { - NSLog(@"Thread not found error: %@", error.description); - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveThreadNotFoundNotification - object:self - userInfo:nil]; - return; - } - - if (statusCode == 429) { - [NCLog log:@"Brute-force protected, received 429 while receiving messages. No further polling."]; - return; - } - - if (statusCode != 304) { - NSLog(@"Could not get new chat messages. Error: %@", error.description); - } - } else { - // Update last chat block - [self updateLastChatBlockWithNewestKnown:lastKnownMessage]; - - // Store new messages - if (messages.count > 0) { - [self storeMessages:messages]; - [self checkForNewMessagesFromMessageId:messageId]; - - for (NSDictionary *messageDict in messages) { - NCChatMessage *message = [NCChatMessage messageWithDictionary:messageDict andAccountId:self->_account.accountId]; - - // When we receive a "history_cleared" message, we don't continue here, as otherwise - // we would request new message, but instead, we need to request the inital history again - if ([message.systemMessage isEqualToString:@"history_cleared"]) { - return; - } - } - } - } - - self->_hasReceivedMessagesFromServer = YES; - - [self checkLastCommonReadMessage:lastCommonReadMessage]; - - if ([error underlyingError].code != NSURLErrorCancelled) { - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - [self startReceivingChatMessagesFromMessagesId:lastChatBlock.newestMessageId withTimeout:YES]; - } - }]; -} - -- (void)startReceivingNewChatMessages -{ - NCChatBlock *lastChatBlock = [self chatBlocksForRoomOrThread].lastObject; - [self startReceivingChatMessagesFromMessagesId:lastChatBlock.newestMessageId withTimeout:NO]; -} - -- (void)stopReceivingNewChatMessages -{ - _stopChatMessagesPoll = YES; - [_pullMessagesTask cancel]; -} - -- (void)sendChatMessage:(NSString *)message replyTo:(NSInteger)replyTo referenceId:(NSString *)referenceId silently:(BOOL)silently -{ - BGTaskHelper *bgTask = [BGTaskHelper startBackgroundTaskWithName:@"NCChatControllerSendMessage" expirationHandler:^(BGTaskHelper *task) { - [NCLog log:@"ExpirationHandler called - sendChatMessage"]; - }]; - - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:message forKey:@"message"]; - - __block NSInteger retryCount; - - if (referenceId) { - // Reset offline message flag before retrying to send to prevent race conditions and - // possible ending up with multiple identical messages sent - [self transactionForMessageWithReferenceId:referenceId withBlock:^(NCChatMessage *message) { - message.isOfflineMessage = NO; - retryCount = message.offlineMessageRetryCount; - }]; - } - - [[NCAPIController sharedInstance] sendChatMessage:message toRoom:_room.token threadTitle:nil replyTo:replyTo referenceId:referenceId silently:silently forAccount:_account completionBlock:^(OcsError *error) { - if (referenceId) { - [userInfo setObject:referenceId forKey:@"referenceId"]; - } - - if (error) { - [userInfo setObject:error forKey:@"error"]; - - if (referenceId) { - if (retryCount >= 5) { - // After 5 retries, we assume sending is not possible - [self transactionForMessageWithReferenceId:referenceId withBlock:^(NCChatMessage *message) { - message.sendingFailed = YES; - message.isOfflineMessage = NO; - }]; - - } else { - [self transactionForMessageWithReferenceId:referenceId withBlock:^(NCChatMessage *message) { - message.sendingFailed = NO; - message.isOfflineMessage = YES; - message.offlineMessageRetryCount = (++retryCount); - }]; - - [userInfo setObject:@(YES) forKey:@"isOfflineMessage"]; - } - } - - [NCLog log:[NSString stringWithFormat:@"Could not send chat message. Error: %@", error.description]]; - } else { - [[NCIntentController sharedInstance] donateSendMessageIntentForRoom:self->_room]; - } - - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidSendChatMessageNotification - object:self - userInfo:userInfo]; - - [bgTask stopBackgroundTask]; - }]; -} - -- (void)sendChatMessage:(NCChatMessage *)message { - if ([message.messageType isEqualToString:kMessageTypeVoiceMessage]) { - NSMutableDictionary *talkMetaData = [NSMutableDictionary new]; - [talkMetaData setObject:@"voice-message" forKey:@"messageType"]; - - if (message.parentMessageId > 0) { - [talkMetaData setObject:@(message.parentMessageId) forKey:@"replyTo"]; - } - - if ([self isThreadController]) { - [talkMetaData setObject:@(self.threadId) forKey:@"threadId"]; - } - - void (^uploadCompletion)(NSInteger, NSString *) = ^(NSInteger statusCode, NSString *errorMessage) { - switch (statusCode) { - case 200: - NSLog(@"Successfully uploaded and shared voice message."); - break; - case 403: - NSLog(@"Failed to share voice message."); - break; - case 404: - case 409: - NSLog(@"Failed to check or create attachment folder."); - break; - case 507: - NSLog(@"User storage quota exceeded."); - break; - default: - NSLog(@"Failed to upload voice message with error code: %ld", (long)statusCode); - break; - } - }; - - if (_room.supportsConversationSubfolders) { - NSString *fileName = message.message; - - [[NCAPIController sharedInstance] probeConversationAttachmentFolderInRoom:_room.token - withFileNames:@[fileName] - forAccount:_account - completionBlock:^(NSString *draftFolder, - NSArray *renames, - NSError *error) { - if (error || !draftFolder) { - NSLog(@"Could not probe conversation attachment folder for voice message."); - return; - } - - NSString *fileExtension = [NSURL URLWithString:fileName].pathExtension; - NSString *extensionSuffix = fileExtension.length > 0 ? [@"." stringByAppendingString:fileExtension] : @""; - NSString *tempName = [[NSUUID UUID].UUIDString stringByAppendingString:extensionSuffix]; - NSString *draftPath = [NSString stringWithFormat:@"%@/%@", draftFolder, tempName]; - NSString *serverPath = [NSString stringWithFormat:@"/%@", draftPath]; - NSString *fileServerURL = [NSString stringWithFormat:@"%@/remote.php/dav/files/%@%@", self->_account.server, self->_account.userId, serverPath]; - - [ChatFileUploader uploadFileWithLocalPath:message.file.fileStatus.fileLocalPath - fileServerURL:fileServerURL - fileServerPath:serverPath - draftPath:draftPath - talkMetaData:talkMetaData - temporaryMessage:message - room:self.room - completion:uploadCompletion]; - }]; - } else { - [[NCAPIController sharedInstance] uniqueNameForFileUploadWithName:message.message - isOriginalName:YES - forAccount:_account - completionBlock:^(NSString *fileServerURL, NSString *fileServerPath, NSInteger _, NSString *__) { - if (fileServerURL && fileServerPath) { - [ChatFileUploader uploadFileWithLocalPath:message.file.fileStatus.fileLocalPath - fileServerURL:fileServerURL - fileServerPath:fileServerPath - draftPath:nil - talkMetaData:talkMetaData - temporaryMessage:message - room:self.room - completion:uploadCompletion]; - } else { - NSLog(@"Could not find unique name for voice message file."); - } - }]; - } - } else { - [self sendChatMessage:message.sendingMessage replyTo:message.parentMessageId referenceId:message.referenceId silently:message.isSilent]; - } -} - -- (void)checkLastCommonReadMessage:(NSInteger)lastCommonReadMessage -{ - if (lastCommonReadMessage > 0) { - BOOL newerCommonReadReceived = lastCommonReadMessage > self->_room.lastCommonReadMessage; - - if (newerCommonReadReceived) { - self->_room.lastCommonReadMessage = lastCommonReadMessage; - [[NCRoomsManager shared] updateLastCommonReadMessage:lastCommonReadMessage forRoom:self->_room]; - - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:self->_room.token forKey:@"room"]; - [userInfo setObject:@(lastCommonReadMessage) forKey:@"lastCommonReadMessage"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveNewerCommonReadMessageNotification - object:self - userInfo:userInfo]; - } - } -} - -- (BOOL)isChatBeingBlocked:(NSInteger)statusCode -{ - if (statusCode == 412) { - return YES; - } - return NO; -} - -- (void)notifyChatIsBlocked -{ - NSMutableDictionary *userInfo = [NSMutableDictionary new]; - [userInfo setObject:_room.token forKey:@"room"]; - [[NSNotificationCenter defaultCenter] postNotificationName:NCChatControllerDidReceiveChatBlockedNotification - object:self - userInfo:userInfo]; -} - -- (void)stopChatController -{ - [self stopReceivingNewChatMessages]; - [self stopReceivingChatHistory]; - self.hasReceivedMessagesFromServer = NO; -} - -- (void)clearHistoryAndResetChatController -{ - [_pullMessagesTask cancel]; - [self removeAllStoredMessagesAndChatBlocks]; - _room.lastReadMessage = 0; -} - -- (BOOL)hasHistoryFromMessageId:(NSInteger)messageId -{ - NCChatBlock *firstChatBlock = [self chatBlocksForRoomOrThread].firstObject; - if (firstChatBlock && firstChatBlock.oldestMessageId == messageId) { - return firstChatBlock.hasHistory; - } - return YES; -} - -- (void)getMessageContextForMessageId:(NSInteger)messageId withLimit:(NSInteger)limit withCompletionBlock:(GetMessagesContextCompletionBlock)block -{ - [[NCAPIController sharedInstance] getMessageContextInRoom:self.room.token forMessageId:messageId inThread:_threadId withLimit:limit forAccount:self.account completionBlock:^(NSArray *messages, OcsError *error) { - if (error) { - if (block) { - block(nil); - } - - return; - } - - for (NCChatMessage *message in messages) { - if (!message.file) { - continue; - } - - // Try to get the stored preview height from our database, when the message is already stored - NCChatMessage *managedMessage = [NCChatMessage objectsWhere:@"internalId = %@", message.internalId].firstObject; - - if (managedMessage && managedMessage.file && managedMessage.file.previewImageHeight > 0) { - message.file.previewImageHeight = managedMessage.file.previewImageHeight; - } - } - - if (block) { - block(messages); - } - }]; -} - -- (void)getSingleMessageWithMessageId:(NSInteger)messageId withCompletionBlock:(GetSingleMessageCompletionBlock)block -{ - NSPredicate *query = [NSPredicate predicateWithFormat:@"accountId = %@ AND token = %@ AND messageId == %ld", _account.accountId, _room.token, (long)messageId]; - RLMResults *managedMessages = [NCChatMessage objectsWithPredicate:query]; - NCChatMessage *message = [managedMessages firstObject]; - - if (message) { - block(message); - return; - } - - [[NCAPIController sharedInstance] receiveChatMessagesOfRoom:_room.token fromLastMessageId:messageId inThread:0 history:YES includeLastMessage:YES timeout:NO limit:1 lastCommonReadMessage:0 setReadMarker:NO markNotificationsAsRead:NO forAccount:_account completionBlock:^(NSArray *messages, NSInteger lastKnownMessage, NSInteger lastCommonReadMessage, OcsError *error, NSInteger statusCode) { - if (error) { - NSLog(@"Could not get single message from server. Error: %@", error.description); - block(nil); - } else { - NCChatMessage* message = [NCChatMessage messageWithDictionary:[messages firstObject] andAccountId:self->_account.accountId]; - - // The API will return the previous available message in case the messageId is not found. - // Therefore we need to make sure, that we received the message we are looking for. - if (message && message.messageId == messageId) { - block(message); - return; - } - - block(nil); - } - }]; -} - -@end diff --git a/NextcloudTalk/Chat/NCChatController.swift b/NextcloudTalk/Chat/NCChatController.swift new file mode 100644 index 000000000..fe7c76d0a --- /dev/null +++ b/NextcloudTalk/Chat/NCChatController.swift @@ -0,0 +1,1086 @@ +// +// SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +public extension NSNotification.Name { + static let NCChatControllerDidReceiveInitialChatHistory = NSNotification.Name("NCChatControllerDidReceiveInitialChatHistoryNotification") + static let NCChatControllerDidReceiveInitialChatHistoryOffline = NSNotification.Name("NCChatControllerDidReceiveInitialChatHistoryOfflineNotification") + static let NCChatControllerDidReceiveChatHistory = NSNotification.Name("NCChatControllerDidReceiveChatHistoryNotification") + static let NCChatControllerDidReceiveChatMessages = NSNotification.Name("NCChatControllerDidReceiveChatMessagesNotification") + static let NCChatControllerDidSendChatMessage = NSNotification.Name("NCChatControllerDidSendChatMessageNotification") + static let NCChatControllerDidReceiveChatBlocked = NSNotification.Name("NCChatControllerDidReceiveChatBlockedNotification") + static let NCChatControllerDidReceiveNewerCommonReadMessage = NSNotification.Name("NCChatControllerDidReceiveNewerCommonReadMessageNotification") + static let NCChatControllerDidReceiveUpdateMessage = NSNotification.Name("NCChatControllerDidReceiveUpdateMessageNotification") + static let NCChatControllerDidReceiveHistoryCleared = NSNotification.Name("NCChatControllerDidReceiveHistoryClearedNotification") + static let NCChatControllerDidReceiveCallStartedMessage = NSNotification.Name("NCChatControllerDidReceiveCallStartedMessageNotification") + static let NCChatControllerDidReceiveCallEndedMessage = NSNotification.Name("NCChatControllerDidReceiveCallEndedMessageNotification") + static let NCChatControllerDidReceiveMessagesInBackground = NSNotification.Name("NCChatControllerDidReceiveMessagesInBackgroundNotification") + static let NCChatControllerDidReceiveThreadMessage = NSNotification.Name("NCChatControllerDidReceiveThreadMessageNotification") + static let NCChatControllerDidReceiveThreadNotFound = NSNotification.Name("NCChatControllerDidReceiveThreadNotFoundNotification") +} + +public class NCChatController: NSObject { + + public var room: NCRoom + public var threadId: Int = 0 + public var hasReceivedMessagesFromServer = false + + private let account: TalkAccount + private var stopChatMessagesPoll = false + private var getHistoryTask: URLSessionDataTask? + private var pullMessagesTask: URLSessionDataTask? + + public init!(for room: NCRoom) { + guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: room.accountId) else { return nil } + + self.room = room + self.account = account + + super.init() + + AllocationTracker.shared.addAllocation("NCChatController") + } + + public init!(forThreadId threadId: Int, in room: NCRoom) { + guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: room.accountId) else { return nil } + + self.room = room + self.threadId = threadId + self.account = account + + super.init() + + AllocationTracker.shared.addAllocation("NCChatController") + } + + deinit { + AllocationTracker.shared.removeAllocation("NCChatController") + } + + private var isThreadController: Bool { + return threadId > 0 + } + + private func willBeVisibleMessage(_ message: NCChatMessage) -> Bool { + // Update messages are not visible in normal chats or thread views + if message.isUpdateMessage { + return false + } + + // Thread messages are not visible in normal chat views. + if !isThreadController, message.isThreadMessage() { + return false + } + + // In thread controller mode we only receive thread messages, + // so no check for non-thread messages is needed + + return true + } + + // MARK: - Database + + private func managedSortedBlocksForRoomOrThread() -> RLMResults { + let predicate: NSPredicate + if isThreadController { + predicate = NSPredicate(format: "internalId = %@ AND threadId = %ld", room.internalId, threadId) + } else { + predicate = NSPredicate(format: "internalId = %@ AND threadId = 0", room.internalId) + } + + return NCChatBlock.objects(with: predicate).sortedResults(usingKeyPath: "newestMessageId", ascending: true) + } + + private func chatBlocksForRoomOrThread() -> [NCChatBlock] { + let managedSortedBlocks = managedSortedBlocksForRoomOrThread() + + // Create an unmanaged copy of the blocks + var sortedBlocks: [NCChatBlock] = [] + for case let managedBlock as NCChatBlock in managedSortedBlocks { + sortedBlocks.append(NCChatBlock(value: managedBlock)) + } + + return sortedBlocks + } + + private func getBatchOfMessages(inBlock chatBlock: NCChatBlock?, fromMessageId messageId: Int, included: Bool, ensureIncludesMessageId ensuredMessageId: Int) -> [NCChatMessage] { + let blockOldest = chatBlock?.oldestMessageId ?? 0 + let blockNewest = chatBlock?.newestMessageId ?? 0 + let fromMessageId = messageId > 0 ? messageId : blockNewest + + let query: NSPredicate + if isThreadController { + if included { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND threadId = %ld AND messageId >= %ld AND messageId <= %ld", account.accountId, room.token, threadId, blockOldest, fromMessageId) + } else { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND threadId = %ld AND messageId >= %ld AND messageId < %ld", account.accountId, room.token, threadId, blockOldest, fromMessageId) + } + } else { + if included { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId >= %ld AND messageId <= %ld", account.accountId, room.token, blockOldest, fromMessageId) + } else { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId >= %ld AND messageId < %ld", account.accountId, room.token, blockOldest, fromMessageId) + } + } + + let managedSortedMessages = NCChatMessage.objects(with: query).sortedResults(usingKeyPath: "messageId", ascending: true) + + // Create an unmanaged copy of the messages + var sortedMessages: [NCChatMessage] = [] + var numberOfStoredVisibleMessages = 0 + + // When there's no message we need to ensure being included, we just assume it's included to enforce the default limit + var reachedEnsuredMessageId = ensuredMessageId <= 0 + + // Iterate backwards and check if we gathered enough visible messages (or more, if we need to include the unread marker) + var index = Int(managedSortedMessages.count) - 1 + while index >= 0 { + guard let managedMessage = managedSortedMessages.object(at: UInt(index)) as? NCChatMessage else { + index -= 1 + continue + } + + let sortedMessage = NCChatMessage(value: managedMessage) + + // Since we iterate backwards, insert the object at the beginning of the array to keep it sorted + sortedMessages.insert(sortedMessage, at: 0) + + if sortedMessage.messageId == ensuredMessageId { + reachedEnsuredMessageId = true + } + + // We only count visible messages and we only count, if we already found the message that we need to ensure + if reachedEnsuredMessageId, willBeVisibleMessage(sortedMessage) { + numberOfStoredVisibleMessages += 1 + } + + // Break in case we found the ensured message and we hit the visible message limit + if reachedEnsuredMessageId, numberOfStoredVisibleMessages >= NCAPIController.shared.kReceivedChatMessagesLimit { + break + } + + index -= 1 + } + + NSLog("Returning batch of %ld messages", sortedMessages.count) + + return sortedMessages + } + + private func getNewStoredMessages(inBlock chatBlock: NCChatBlock?, sinceMessageId messageId: Int) -> [NCChatMessage] { + let blockNewest = chatBlock?.newestMessageId ?? 0 + + let query: NSPredicate + if isThreadController { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND threadId = %ld AND messageId > %ld AND messageId <= %ld", account.accountId, room.token, threadId, messageId, blockNewest) + } else { + query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId > %ld AND messageId <= %ld AND (isThread == 0 OR threadId == 0 OR threadId == messageId)", account.accountId, room.token, messageId, blockNewest) + } + + let managedSortedMessages = NCChatMessage.objects(with: query).sortedResults(usingKeyPath: "messageId", ascending: true) + + // Create an unmanaged copy of the messages + var sortedMessages: [NCChatMessage] = [] + for case let managedMessage as NCChatMessage in managedSortedMessages { + sortedMessages.append(NCChatMessage(value: managedMessage)) + } + + return sortedMessages + } + + public func storeMessages(_ messages: [[AnyHashable: Any]], with realm: RLMRealm) { + // Add or update messages + for messageDict in messages { + // messageWithDictionary takes care of setting a potential available parentId + guard let message = NCChatMessage(dictionary: messageDict, andAccountId: account.accountId) else { continue } + + if let referenceId = message.referenceId, !referenceId.isEmpty { + if let managedTemporaryMessage = NCChatMessage.objects(where: "referenceId = %@ AND isTemporary = true", referenceId).firstObject() as? NCChatMessage { + realm.delete(managedTemporaryMessage) + } + } + + if let managedMessage = NCChatMessage.objects(where: "internalId = %@", message.internalId ?? "").firstObject() as? NCChatMessage { + NCChatMessage.update(managedMessage, with: message, isRoomLastMessage: false) + } else { + realm.add(message) + } + + if message.isThreadCreatedMessage { + let thread = NCThread.createThread(from: message, andAccountId: message.accountId ?? account.accountId) + realm.add(thread) + } else if message.isThreadMessage() { + NCThread.updateThread(withThreadMessage: message) + } + + let parentDict = messageDict["parent"] as? [AnyHashable: Any] + if let parent = NCChatMessage(dictionary: parentDict, andAccountId: account.accountId) { + if let managedParentMessage = NCChatMessage.objects(where: "internalId = %@", parent.internalId ?? "").firstObject() as? NCChatMessage { + // updateChatMessage takes care of not setting a parentId to nil if there was one before + NCChatMessage.update(managedParentMessage, with: parent, isRoomLastMessage: false) + } else { + realm.add(parent) + } + } + } + } + + private func storeMessages(_ messages: [[String: Any]]) { + let realm = RLMRealm.default() + try? realm.transaction { + self.storeMessages(messages.map { $0 as [AnyHashable: Any] }, with: realm) + } + } + + public func hasOlderStoredMessagesThanMessageId(_ messageId: Int) -> Bool { + let query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId < %ld", account.accountId, room.token, messageId) + return NCChatMessage.objects(with: query).count > 0 + } + + private func removeAllStoredMessagesAndChatBlocks() { + let realm = RLMRealm.default() + try? realm.transaction { + let query = NSPredicate(format: "accountId = %@ AND token = %@", self.account.accountId, self.room.token) + realm.deleteObjects(NCChatMessage.objects(with: query)) + realm.deleteObjects(NCChatBlock.objects(with: query)) + let threadsQuery = NSPredicate(format: "accountId = %@ AND roomToken = %@", self.account.accountId, self.room.token) + realm.deleteObjects(NCThread.objects(with: threadsQuery)) + } + } + + public func removeExpiredMessages() { + let realm = RLMRealm.default() + let currentTimestamp = Int(Date().timeIntervalSince1970) + try? realm.transaction { + let query = NSPredicate(format: "accountId = %@ AND token = %@ AND expirationTimestamp > 0 AND expirationTimestamp <= %ld", self.account.accountId, self.room.token, currentTimestamp) + realm.deleteObjects(NCChatMessage.objects(with: query)) + } + } + + private func updateLastChatBlock(withNewestKnown newestKnown: Int) { + let realm = RLMRealm.default() + try? realm.transaction { + let managedSortedBlocks = self.managedSortedBlocksForRoomOrThread() + let lastBlock = managedSortedBlocks.lastObject() as? NCChatBlock + if newestKnown > 0 { + lastBlock?.newestMessageId = newestKnown + } + } + } + + private func updateChatBlocks(withLastKnown lastKnown: Int) { + if lastKnown <= 0 { + return + } + + // Safety check: prevent storing a messageId older than the thread's first message as block's oldestMessageId when in a thread controller + let oldestMessageKnown = (isThreadController && lastKnown < threadId) ? threadId : lastKnown + + let realm = RLMRealm.default() + try? realm.transaction { + let managedSortedBlocks = self.managedSortedBlocksForRoomOrThread() + guard let lastBlock = managedSortedBlocks.lastObject() as? NCChatBlock else { return } + + let count = Int(managedSortedBlocks.count) + // There is more than one chat block stored + if count > 1 { + var index = count - 2 + while index >= 0 { + guard let block = managedSortedBlocks.object(at: UInt(index)) as? NCChatBlock else { + index -= 1 + continue + } + + // Merge blocks if the lastKnown message is inside the current block + if lastKnown >= block.oldestMessageId, lastKnown <= block.newestMessageId { + lastBlock.oldestMessageId = block.oldestMessageId + realm.delete(block) + break + // Update lastBlock if the lastKnown message is between the 2 blocks + } else if lastKnown > block.newestMessageId { + lastBlock.oldestMessageId = oldestMessageKnown + break + // The current block is completely included in the retrieved history + // This could happen if we vary the message limit when fetching messages + // Delete included block + } else if lastKnown < block.oldestMessageId { + realm.delete(block) + } + + index -= 1 + } + // There is just one chat block stored + } else { + lastBlock.oldestMessageId = oldestMessageKnown + } + } + } + + private func updateChatBlocks(withReceivedMessages messages: [[String: Any]]?, newestKnown: Int, andLastKnown lastKnown: Int) { + let sortedMessages = sortedMessages(fromMessageArray: messages) + let newestMessageReceived = sortedMessages.last + let newestMessageKnown = newestKnown > 0 ? newestKnown : (newestMessageReceived?.messageId ?? 0) + // Safety check: prevent storing a messageId older than the thread's first message as block's oldestMessageId when in a thread controller + let oldestMessageKnown = (isThreadController && lastKnown < threadId) ? threadId : lastKnown + + let realm = RLMRealm.default() + try? realm.transaction { + let managedSortedBlocks = self.managedSortedBlocksForRoomOrThread() + + // Create new chat block + let newBlock = NCChatBlock() + newBlock.internalId = self.room.internalId + newBlock.accountId = self.room.accountId + newBlock.token = self.room.token + newBlock.threadId = self.threadId + newBlock.oldestMessageId = oldestMessageKnown + newBlock.newestMessageId = newestMessageKnown + newBlock.hasHistory = true + + let count = Int(managedSortedBlocks.count) + // There is at least one chat block stored + if count > 0 { + var index = count - 1 + while index >= 0 { + guard let block = managedSortedBlocks.object(at: UInt(index)) as? NCChatBlock else { + index -= 1 + continue + } + + // Merge blocks if the lastKnown message is inside the current block + if lastKnown >= block.oldestMessageId, lastKnown <= block.newestMessageId { + block.newestMessageId = newestMessageKnown + break + // Add new block if it didn't reach the previous block + } else if lastKnown > block.newestMessageId { + realm.add(newBlock) + break + // The current block is completely included in the retrieved history + // This could happen if we vary the message limit when fetching messages + // Delete included block + } else if lastKnown < block.oldestMessageId { + realm.delete(block) + } + + index -= 1 + } + // No chat blocks stored yet, add new chat block + } else { + realm.add(newBlock) + } + } + } + + private func updateHistoryFlagInFirstBlock() { + let realm = RLMRealm.default() + try? realm.transaction { + let managedSortedBlocks = self.managedSortedBlocksForRoomOrThread() + let firstChatBlock = managedSortedBlocks.firstObject() as? NCChatBlock + firstChatBlock?.hasHistory = false + } + } + + private func transactionForMessage(withReferenceId referenceId: String, block: @escaping (_ message: NCChatMessage?) -> Void) { + let realm = RLMRealm.default() + try? realm.transaction { + let managedChatMessage = NCChatMessage.objects(where: "referenceId = %@ AND isTemporary = true", referenceId).firstObject() as? NCChatMessage + block(managedChatMessage) + } + } + + private func sortedMessages(fromMessageArray messages: [[String: Any]]?) -> [NCChatMessage] { + guard let messages else { return [] } + + var sortedMessages: [NCChatMessage] = [] + sortedMessages.reserveCapacity(messages.count) + for messageDict in messages { + if let message = NCChatMessage(dictionary: messageDict as [AnyHashable: Any]) { + sortedMessages.append(message) + } + } + + // Sort by messageId + sortedMessages.sort { $0.messageId < $1.messageId } + + return sortedMessages + } + + // MARK: - Chat + + public func getTemporaryMessages() -> [NCChatMessage] { + let query = NSPredicate(format: "accountId = %@ AND token = %@ AND isTemporary = true", account.accountId, room.token) + let managedTemporaryMessages = NCChatMessage.objects(with: query) + let managedSortedTemporaryMessages = managedTemporaryMessages.sortedResults(usingKeyPath: "timestamp", ascending: true) + + // Mark temporary messages sent more than 12 hours ago as failed-to-send messages + let twelveHoursAgoTimestamp = Int(Date().timeIntervalSince1970) - (60 * 60 * 12) + + for case let temporaryMessage as NCChatMessage in managedTemporaryMessages where temporaryMessage.timestamp < twelveHoursAgoTimestamp { + let realm = RLMRealm.default() + try? realm.transaction { + temporaryMessage.isOfflineMessage = false + temporaryMessage.sendingFailed = true + } + } + + // Create an unmanaged copy of the messages + var sortedMessages: [NCChatMessage] = [] + for case let managedMessage as NCChatMessage in managedSortedTemporaryMessages { + sortedMessages.append(NCChatMessage(value: managedMessage)) + } + + return sortedMessages + } + + public func updateHistoryInBackground(completion: ((_ error: OcsError?) -> Void)?) { + // If there's a pull task running right now, we should not interfere with that + if let pullMessagesTask, pullMessagesTask.state == .running { + completion?(OcsError(withError: NSError(domain: NSCocoaErrorDomain, code: 0), withTask: nil)) + return + } + + let lastChatBlock = chatBlocksForRoomOrThread().last + var expired = false + + let bgTask = BGTaskHelper.startBackgroundTask(withName: "updateHistoryInBackgroundWithCompletionBlock") { _ in + NCLog.log("ExpirationHandler called updateHistoryInBackgroundWithCompletionBlock") + expired = true + + // Make sure we actually end a running pullMessagesTask, because otherwise the completion handler might not be called in time + self.pullMessagesTask?.cancel() + } + + pullMessagesTask = NCAPIController.sharedInstance().receiveChatMessages(ofRoom: room.token, fromLastMessageId: lastChatBlock?.newestMessageId ?? 0, inThread: threadId, history: false, includeLastMessage: false, timeout: false, limit: NCAPIController.shared.kReceivedChatMessagesLimit, lastCommonReadMessage: room.lastCommonReadMessage, setReadMarker: false, markNotificationsAsRead: false, forAccount: account) { messages, lastKnownMessage, lastCommonReadMessage, error, _ in + if expired { + completion?(error) + bgTask.stopBackgroundTask() + return + } + + if let error { + NSLog("Could not get background chat history. Error: \(error.description)") + } else { + // Update chat blocks + self.updateLastChatBlock(withNewestKnown: lastKnownMessage) + + // Store new messages + if let messages, !messages.isEmpty { + // In case we finish after the app already got active again, notify any potential view controller + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = self.room.token + + for messageDict in messages { + let message = NCChatMessage(dictionary: messageDict as [AnyHashable: Any], andAccountId: self.account.accountId) + + if message?.systemMessage == "history_cleared" { + self.clearHistoryAndResetChatController() + + userInfo["historyCleared"] = message + NotificationCenter.default.post(name: .NCChatControllerDidReceiveHistoryCleared, object: self, userInfo: userInfo) + return + } + } + + self.storeMessages(messages) + self.checkLastCommonReadMessage(lastCommonReadMessage) + + NotificationCenter.default.post(name: .NCChatControllerDidReceiveMessagesInBackground, object: self, userInfo: userInfo) + } + } + + completion?(error) + + bgTask.stopBackgroundTask() + } + } + + public func checkForNewMessages(fromMessageId messageId: Int) { + let lastChatBlock = chatBlocksForRoomOrThread().last + let storedMessages = getNewStoredMessages(inBlock: lastChatBlock, sinceMessageId: messageId) + + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + + guard !storedMessages.isEmpty else { return } + + for message in storedMessages { + // Notify if "call started" have been received + if message.systemMessage == "call_started" { + NotificationCenter.default.post(name: .NCChatControllerDidReceiveCallStartedMessage, object: self, userInfo: userInfo) + } + // Notify if "call ended" have been received + if message.systemMessage == "call_ended" || + message.systemMessage == "call_ended_everyone" || + message.systemMessage == "call_missed" || + message.systemMessage == "call_tried" { + NotificationCenter.default.post(name: .NCChatControllerDidReceiveCallEndedMessage, object: self, userInfo: userInfo) + } + // Notify if an "update message" has been received + if message.isUpdateMessage || message.isVisibleUpdateMessage { + userInfo["updateMessage"] = message + NotificationCenter.default.post(name: .NCChatControllerDidReceiveUpdateMessage, object: self, userInfo: userInfo) + } + // Notify if a "thread message" has been received + if message.isThreadMessage() { + userInfo["threadMessage"] = message + NotificationCenter.default.post(name: .NCChatControllerDidReceiveThreadMessage, object: self, userInfo: userInfo) + } + // Notify if "history cleared" has been received + if message.systemMessage == "history_cleared" { + userInfo["historyCleared"] = message + NotificationCenter.default.post(name: .NCChatControllerDidReceiveHistoryCleared, object: self, userInfo: userInfo) + return + } + } + + userInfo["messages"] = storedMessages + userInfo["firstNewMessagesAfterHistory"] = !hasReceivedMessagesFromServer + NotificationCenter.default.post(name: .NCChatControllerDidReceiveChatMessages, object: self, userInfo: userInfo) + + updateLastMessageIfNeeded(fromMessages: storedMessages) + } + + private func updateLastMessageIfNeeded(fromMessages storedMessages: [NCChatMessage]) { + // Try to find the last non-update message - Messages are already sorted by messageId here + var lastNonUpdateMessage: NCChatMessage? + let lastMessage = storedMessages.last + + var index = storedMessages.count - 1 + while index >= 0 { + let tempMessage = storedMessages[index] + if !tempMessage.isUpdateMessage { + lastNonUpdateMessage = tempMessage + break + } + index -= 1 + } + + // Make sure we update the unread flags for the room (lastMessage can already be set, but there still might be unread flags) + if let lastMessage, lastMessage.timestamp >= self.room.lastActivity { + // Make sure our local reference to the room also has the correct lastActivity set + if let lastNonUpdateMessage { + self.room.lastActivity = lastNonUpdateMessage.timestamp + } + + // We always want to set the room to have no unread messages, optionally we also want to update the last message, if there's one + NCRoomsManager.shared.setNoUnreadMessages(forRoom: self.room, withLastMessage: lastNonUpdateMessage) + } + } + + public func getInitialChatHistoryForOfflineMode() { + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + + var lastReadMessageId = 0 + if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatReadMarker, for: room) { + lastReadMessageId = room.lastReadMessage + } + + let lastChatBlock = chatBlocksForRoomOrThread().last + let storedMessages = getBatchOfMessages(inBlock: lastChatBlock, fromMessageId: lastChatBlock?.newestMessageId ?? 0, included: true, ensureIncludesMessageId: lastReadMessageId) + userInfo["messages"] = storedMessages + NotificationCenter.default.post(name: .NCChatControllerDidReceiveInitialChatHistoryOffline, object: self, userInfo: userInfo) + } + + public func getInitialChatHistory() { + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + + // Clear expired messages + removeExpiredMessages() + + var lastReadMessageId = 0 + // If the chat supports read markers and this is not a thread controller, start from the room's last read message. + // In thread controllers, always start from the latest message (lastReadMessageId = 0) because the room's last read message + // might be outdated and older than the thread's first message, which would lead to a 304 response. + if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatReadMarker, for: room), !isThreadController { + lastReadMessageId = room.lastReadMessage + } + + fetchHistoryUntilVisible(fromMessageId: lastReadMessageId, forInitialChatHistory: true, isFirstIteration: true) { messages, lastCommonReadMessage, error, statusCode in + if let error { + if self.isChatBeingBlocked(statusCode) { + self.notifyChatIsBlocked() + return + } + userInfo["error"] = error + NSLog("Could not get initial chat history. Error: \(error.description)") + } else if let messages, !messages.isEmpty { + userInfo["messages"] = messages + self.updateLastMessageIfNeeded(fromMessages: messages) + } + + NotificationCenter.default.post(name: .NCChatControllerDidReceiveInitialChatHistory, object: self, userInfo: userInfo) + + self.checkLastCommonReadMessage(lastCommonReadMessage) + } + } + + public func getHistoryBatch(fromMessagesId messageId: Int) { + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + + fetchHistoryUntilVisible(fromMessageId: messageId, forInitialChatHistory: false, isFirstIteration: true) { messages, _, error, statusCode in + if statusCode == 304 { + self.updateHistoryFlagInFirstBlock() + } + if let error { + if self.isChatBeingBlocked(statusCode) { + self.notifyChatIsBlocked() + return + } + userInfo["error"] = error + if statusCode != 304 { + NSLog("Could not get chat history. Error: \(error.description)") + } + } else if let messages, !messages.isEmpty { + userInfo["messages"] = messages + } + + NotificationCenter.default.post(name: .NCChatControllerDidReceiveChatHistory, object: self, userInfo: userInfo) + } + } + + private func fetchHistoryUntilVisible(fromMessageId messageId: Int, forInitialChatHistory: Bool, isFirstIteration: Bool, completion: @escaping (_ messages: [NCChatMessage]?, _ lastCommonReadMessage: Int, _ error: OcsError?, _ statusCode: Int) -> Void) { + var messageId = messageId + let lastChatBlock = chatBlocksForRoomOrThread().last + + // First, try to load messages from local storage (DB) + if let lastChatBlock { + let canUseLocalStorage: Bool + + if forInitialChatHistory { + // For initial chat history: make sure messageId is inside the last chat block + canUseLocalStorage = lastChatBlock.newestMessageId > 0 && + messageId >= lastChatBlock.oldestMessageId && + lastChatBlock.newestMessageId >= messageId + } else { + // For history batch: just make sure messageId is newer than last chat block's oldest message + canUseLocalStorage = lastChatBlock.newestMessageId > 0 && + messageId >= lastChatBlock.oldestMessageId + } + + if canUseLocalStorage { + // For initial chat history: always get batch from last chat block's newest message, even if it's not the first iteration. + // For history batch: get batch from the passed messageId. If it's not the first iteration, we will just skip invisible messages + // from previous iterations and not pass them to the chat view. + let storedMessages = getBatchOfMessages(inBlock: lastChatBlock, + fromMessageId: forInitialChatHistory ? lastChatBlock.newestMessageId : messageId, + included: forInitialChatHistory, + ensureIncludesMessageId: forInitialChatHistory ? messageId : 0) + + for message in storedMessages { + // Since the passed messageId might not be the lowest one, we update it here to ensure we request the missing messages + if message.messageId < messageId { + messageId = message.messageId + } + + // If there is at least one visible message, we can stop fetching messages and pass them + if willBeVisibleMessage(message) { + completion(storedMessages, 0, nil, 0) + return + } + } + } + } + + // If no messages are found or visible in last chat block, fall back to fetching them from the server + getHistoryTask = NCAPIController.sharedInstance().receiveChatMessages(ofRoom: room.token, fromLastMessageId: messageId, inThread: threadId, history: true, includeLastMessage: forInitialChatHistory, timeout: false, limit: NCAPIController.shared.kReceivedChatMessagesLimit, lastCommonReadMessage: room.lastCommonReadMessage, setReadMarker: true, markNotificationsAsRead: true, forAccount: account) { messages, lastKnownMessage, lastCommonReadMessage, error, statusCode in + if self.stopChatMessagesPoll { + return + } + + // Error handling + if let error { + completion(nil, 0, error, statusCode) + return + } + + // Update chat blocks + // Only store a new block when getting initial history and we are in the first iteration. + // Otherwise, only update the chat blocks with history messages ("backwards"). + if forInitialChatHistory, isFirstIteration { + self.updateChatBlocks(withReceivedMessages: messages, newestKnown: messageId, andLastKnown: lastKnownMessage) + } else { + self.updateChatBlocks(withLastKnown: lastKnownMessage) + } + + // Store new messages + if let messages, !messages.isEmpty { + self.storeMessages(messages) + + let lastChatBlock = self.chatBlocksForRoomOrThread().last + // For initial chat history: always get batch from last chat block's newest message, even if it's not the first iteration. + // For history batch: get batch from the passed messageId. If it's not the first iteration, we will just skip invisible messages + // from previous iterations and not pass them to the chat view. + let history = self.getBatchOfMessages(inBlock: lastChatBlock, + fromMessageId: forInitialChatHistory ? (lastChatBlock?.newestMessageId ?? 0) : messageId, + included: forInitialChatHistory, + ensureIncludesMessageId: forInitialChatHistory ? messageId : 0) + + for message in history where self.willBeVisibleMessage(message) { + completion(history, lastCommonReadMessage, nil, 0) + return + } + + // Prevent infinite loop in case there are no new messages + if statusCode != 304 { + // Recursively fetch messages until finding visible ones + self.fetchHistoryUntilVisible(fromMessageId: lastKnownMessage, + forInitialChatHistory: forInitialChatHistory, + isFirstIteration: false, + completion: completion) + return + } + } + + completion([], 0, nil, 0) + } + } + + public func getHistoryBatchOffline(fromMessagesId messageId: Int) { + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + + let chatBlocks = chatBlocksForRoomOrThread() + var historyBatch: [NCChatMessage] = [] + if !chatBlocks.isEmpty { + var index = chatBlocks.count - 1 + while index >= 0 { + let currentBlock = chatBlocks[index] + var noMoreMessagesToRetrieveInBlock = false + if currentBlock.oldestMessageId < messageId { + let storedMessages = getBatchOfMessages(inBlock: currentBlock, fromMessageId: messageId, included: false, ensureIncludesMessageId: 0) + historyBatch = storedMessages + if !storedMessages.isEmpty { + break + } else { + // We use this flag in case the rest of the messages in current block + // are system messages invisible for the user. + noMoreMessagesToRetrieveInBlock = true + } + } + if index > 0, currentBlock.oldestMessageId == messageId || noMoreMessagesToRetrieveInBlock { + let previousBlock = chatBlocks[index - 1] + let storedMessages = getBatchOfMessages(inBlock: previousBlock, fromMessageId: previousBlock.newestMessageId, included: true, ensureIncludesMessageId: 0) + historyBatch = storedMessages + userInfo["shouldAddBlockSeparator"] = true + break + } + index -= 1 + } + } + + if historyBatch.isEmpty { + userInfo["noMoreStoredHistory"] = true + } + + userInfo["messages"] = historyBatch + NotificationCenter.default.post(name: .NCChatControllerDidReceiveChatHistory, object: self, userInfo: userInfo) + } + + private func stopReceivingChatHistory() { + getHistoryTask?.cancel() + } + + private func startReceivingChatMessages(fromMessagesId messageId: Int, withTimeout timeout: Bool) { + stopChatMessagesPoll = false + pullMessagesTask?.cancel() + pullMessagesTask = NCAPIController.sharedInstance().receiveChatMessages(ofRoom: room.token, fromLastMessageId: messageId, inThread: threadId, history: false, includeLastMessage: false, timeout: timeout, limit: NCAPIController.shared.kReceivedChatMessagesLimit, lastCommonReadMessage: room.lastCommonReadMessage, setReadMarker: true, markNotificationsAsRead: true, forAccount: account) { messages, lastKnownMessage, lastCommonReadMessage, error, statusCode in + if self.stopChatMessagesPoll { + return + } + + if let error { + if self.isChatBeingBlocked(statusCode) { + self.notifyChatIsBlocked() + return + } + + if statusCode == 404 { + NSLog("Thread not found error: \(error.description)") + NotificationCenter.default.post(name: .NCChatControllerDidReceiveThreadNotFound, object: self, userInfo: nil) + return + } + + if statusCode == 429 { + NCLog.log("Brute-force protected, received 429 while receiving messages. No further polling.") + return + } + + if statusCode != 304 { + NSLog("Could not get new chat messages. Error: \(error.description)") + } + } else { + // Update last chat block + self.updateLastChatBlock(withNewestKnown: lastKnownMessage) + + // Store new messages + if let messages, !messages.isEmpty { + self.storeMessages(messages) + self.checkForNewMessages(fromMessageId: messageId) + + for messageDict in messages { + let message = NCChatMessage(dictionary: messageDict as [AnyHashable: Any], andAccountId: self.account.accountId) + + // When we receive a "history_cleared" message, we don't continue here, as otherwise + // we would request new messages, but instead, we need to request the initial history again + if message?.systemMessage == "history_cleared" { + return + } + } + } + } + + self.hasReceivedMessagesFromServer = true + + self.checkLastCommonReadMessage(lastCommonReadMessage) + + if error?.underlyingError.code != NSURLErrorCancelled { + let lastChatBlock = self.chatBlocksForRoomOrThread().last + self.startReceivingChatMessages(fromMessagesId: lastChatBlock?.newestMessageId ?? 0, withTimeout: true) + } + } + } + + public func startReceivingNewChatMessages() { + let lastChatBlock = chatBlocksForRoomOrThread().last + startReceivingChatMessages(fromMessagesId: lastChatBlock?.newestMessageId ?? 0, withTimeout: false) + } + + public func stopReceivingNewChatMessages() { + stopChatMessagesPoll = true + pullMessagesTask?.cancel() + } + + public func sendChatMessage(_ message: String, replyTo: Int, referenceId: String?, silently: Bool) { + let bgTask = BGTaskHelper.startBackgroundTask(withName: "NCChatControllerSendMessage") { _ in + NCLog.log("ExpirationHandler called - sendChatMessage") + } + + var userInfo: [AnyHashable: Any] = [:] + userInfo["message"] = message + + var retryCount = 0 + + if let referenceId { + // Reset offline message flag before retrying to send to prevent race conditions and + // possible ending up with multiple identical messages sent + transactionForMessage(withReferenceId: referenceId) { message in + message?.isOfflineMessage = false + retryCount = message?.offlineMessageRetryCount ?? 0 + } + } + + NCAPIController.sharedInstance().sendChatMessage(message, toRoom: room.token, threadTitle: nil, replyTo: replyTo, referenceId: referenceId, silently: silently, forAccount: account) { error in + if let referenceId { + userInfo["referenceId"] = referenceId + } + + if let error { + userInfo["error"] = error + + if let referenceId { + if retryCount >= 5 { + // After 5 retries, we assume sending is not possible + self.transactionForMessage(withReferenceId: referenceId) { message in + message?.sendingFailed = true + message?.isOfflineMessage = false + } + } else { + self.transactionForMessage(withReferenceId: referenceId) { message in + message?.sendingFailed = false + message?.isOfflineMessage = true + retryCount += 1 + message?.offlineMessageRetryCount = retryCount + } + + userInfo["isOfflineMessage"] = true + } + } + + NCLog.log("Could not send chat message. Error: \(error.description)") + } else { + NCIntentController.sharedInstance().donateSendMessageIntent(for: self.room) + } + + NotificationCenter.default.post(name: .NCChatControllerDidSendChatMessage, object: self, userInfo: userInfo) + + bgTask.stopBackgroundTask() + } + } + + public func send(_ message: NCChatMessage) { + guard message.messageType == kMessageTypeVoiceMessage else { + sendChatMessage(message.sendingMessage, replyTo: message.parentMessageId, referenceId: message.referenceId, silently: message.isSilent) + return + } + + var talkMetaData: [String: Any] = ["messageType": "voice-message"] + + if message.parentMessageId > 0 { + talkMetaData["replyTo"] = message.parentMessageId + } + + if isThreadController { + talkMetaData["threadId"] = threadId + } + + let uploadCompletion: (Int, NSString?) -> Void = { statusCode, _ in + switch statusCode { + case 200: + NSLog("Successfully uploaded and shared voice message.") + case 403: + NSLog("Failed to share voice message.") + case 404, 409: + NSLog("Failed to check or create attachment folder.") + case 507: + NSLog("User storage quota exceeded.") + default: + NSLog("Failed to upload voice message with error code: %ld", statusCode) + } + } + + if room.supportsConversationSubfolders { + let fileName = message.message ?? "" + + NCAPIController.sharedInstance().probeConversationAttachmentFolder(inRoom: room.token, withFileNames: [fileName], forAccount: account) { draftFolder, _, error in + guard error == nil, let draftFolder else { + NSLog("Could not probe conversation attachment folder for voice message.") + return + } + + let fileExtension = URL(string: fileName)?.pathExtension ?? "" + let extensionSuffix = !fileExtension.isEmpty ? ".\(fileExtension)" : "" + let tempName = UUID().uuidString + extensionSuffix + let draftPath = "\(draftFolder)/\(tempName)" + let serverPath = "/\(draftPath)" + let fileServerURL = "\(self.account.server)/remote.php/dav/files/\(self.account.userId)\(serverPath)" + + ChatFileUploader.uploadFile(localPath: message.file()?.fileStatus?.fileLocalPath ?? "", + fileServerURL: fileServerURL, + fileServerPath: serverPath, + draftPath: draftPath, + talkMetaData: talkMetaData, + temporaryMessage: message, + room: self.room, + completion: uploadCompletion) + } + } else { + NCAPIController.sharedInstance().uniqueNameForFileUpload(withName: message.message ?? "", isOriginalName: true, forAccount: account) { fileServerURL, fileServerPath, _, _ in + if let fileServerURL, let fileServerPath { + ChatFileUploader.uploadFile(localPath: message.file()?.fileStatus?.fileLocalPath ?? "", + fileServerURL: fileServerURL, + fileServerPath: fileServerPath, + draftPath: nil, + talkMetaData: talkMetaData, + temporaryMessage: message, + room: self.room, + completion: uploadCompletion) + } else { + NSLog("Could not find unique name for voice message file.") + } + } + } + } + + private func checkLastCommonReadMessage(_ lastCommonReadMessage: Int) { + guard lastCommonReadMessage > 0 else { return } + + let newerCommonReadReceived = lastCommonReadMessage > self.room.lastCommonReadMessage + + if newerCommonReadReceived { + self.room.lastCommonReadMessage = lastCommonReadMessage + NCRoomsManager.shared.updateLastCommonReadMessage(lastCommonReadMessage, forRoom: self.room) + + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = self.room.token + userInfo["lastCommonReadMessage"] = lastCommonReadMessage + NotificationCenter.default.post(name: .NCChatControllerDidReceiveNewerCommonReadMessage, object: self, userInfo: userInfo) + } + } + + private func isChatBeingBlocked(_ statusCode: Int) -> Bool { + return statusCode == 412 + } + + private func notifyChatIsBlocked() { + var userInfo: [AnyHashable: Any] = [:] + userInfo["room"] = room.token + NotificationCenter.default.post(name: .NCChatControllerDidReceiveChatBlocked, object: self, userInfo: userInfo) + } + + public func stop() { + stopReceivingNewChatMessages() + stopReceivingChatHistory() + hasReceivedMessagesFromServer = false + } + + public func clearHistoryAndResetChatController() { + pullMessagesTask?.cancel() + removeAllStoredMessagesAndChatBlocks() + room.lastReadMessage = 0 + } + + public func hasHistory(fromMessageId messageId: Int) -> Bool { + let firstChatBlock = chatBlocksForRoomOrThread().first + if let firstChatBlock, firstChatBlock.oldestMessageId == messageId { + return firstChatBlock.hasHistory + } + return true + } + + public func getMessageContext(forMessageId messageId: Int, withLimit limit: Int, completionBlock block: ((_ messages: [NCChatMessage]?) -> Void)?) { + NCAPIController.sharedInstance().getMessageContext(inRoom: room.token, forMessageId: messageId, inThread: threadId, withLimit: limit, forAccount: account) { messages, error in + if error != nil { + block?(nil) + return + } + + if let messages { + for message in messages { + guard let messageFile = message.file() else { + continue + } + + // Try to get the stored preview height from our database, when the message is already stored + if let managedMessage = NCChatMessage.objects(where: "internalId = %@", message.internalId ?? "").firstObject() as? NCChatMessage, + let managedFile = managedMessage.file(), managedFile.previewImageHeight > 0 { + messageFile.previewImageHeight = managedFile.previewImageHeight + } + } + } + + block?(messages) + } + } + + public func getSingleMessage(withMessageId messageId: Int, completionBlock block: ((_ message: NCChatMessage?) -> Void)?) { + let query = NSPredicate(format: "accountId = %@ AND token = %@ AND messageId == %ld", account.accountId, room.token, messageId) + if let message = NCChatMessage.objects(with: query).firstObject() as? NCChatMessage { + block?(message) + return + } + + NCAPIController.sharedInstance().receiveChatMessages(ofRoom: room.token, fromLastMessageId: messageId, inThread: 0, history: true, includeLastMessage: true, timeout: false, limit: 1, lastCommonReadMessage: 0, setReadMarker: false, markNotificationsAsRead: false, forAccount: account) { messages, _, _, error, _ in + if let error { + NSLog("Could not get single message from server. Error: \(error.description)") + block?(nil) + } else { + let message = NCChatMessage(dictionary: messages?.first, andAccountId: self.account.accountId) + + // The API will return the previous available message in case the messageId is not found. + // Therefore we need to make sure, that we received the message we are looking for. + if let message, message.messageId == messageId { + block?(message) + return + } + + block?(nil) + } + } + } +} diff --git a/NextcloudTalk/NextcloudTalk-Bridging-Header.h b/NextcloudTalk/NextcloudTalk-Bridging-Header.h index 85caf1980..d0cc5fe13 100644 --- a/NextcloudTalk/NextcloudTalk-Bridging-Header.h +++ b/NextcloudTalk/NextcloudTalk-Bridging-Header.h @@ -74,7 +74,6 @@ #import "DRCellSlideGestureRecognizer.h" #import "NCTypes.h" -#import "NCChatController.h" #import "ARDCaptureController.h" #import "NCScreensharingController.h"