// // chat.m // SworIM // // Created by Anurodh Pokharel on 1/25/09. // Copyright 2009 __MyCompanyName__. All rights reserved. // #import "chatViewController.h" #import "MLChatCell.h" #import "MLChatImageCell.h" #import "MLChatMapsCell.h" #import "MLLinkCell.h" #import "MLReloadCell.h" #import "MLUploadQueueCell.h" #import "ActiveChatsViewController.h" #import "AESGcm.h" #import "DataLayer.h" #import "HelperTools.h" #import "MBProgressHUD.h" #import "MLChatInputContainer.h" #import "MLChatViewHelper.h" #import "MLConstants.h" #import "MLFiletransfer.h" #import "MLImageManager.h" #import "MLMucProcessor.h" #import "MLVoIPProcessor.h" #import "MLNotificationQueue.h" #import "MLOMEMO.h" #import "MLSearchViewController.h" #import "MLXEPSlashMeHandler.h" #import "MonalAppDelegate.h" #import "xmpp.h" #import "XMPPMessage.h" #import #import #define UPLOAD_TYPE_IMAGE @"UploadTypeImage"; #define UPLOAD_TYPE_URL @"UploadTypeURL"; @import AVFoundation; @import MobileCoreServices; @import QuartzCore.CATransaction; @import QuartzCore; @import UniformTypeIdentifiers.UTCoreTypes; @class MLEmoji; @interface chatViewController() { BOOL _isTyping; monal_void_block_t _cancelTypingNotification; monal_void_block_t _cancelLastInteractionTimer; NSMutableDictionary* _localMLContactCache; BOOL _isRecording; BOOL _isAtBottom; monal_void_block_t _scrollToBottomTimer; } @property (nonatomic, strong) NSDateFormatter* destinationDateFormat; @property (nonatomic, strong) NSCalendar* gregorian; @property (nonatomic, assign) NSInteger thisyear; @property (nonatomic, assign) NSInteger thismonth; @property (nonatomic, assign) NSInteger thisday; @property (nonatomic, strong) MBProgressHUD* uploadHUD; @property (nonatomic, strong) MBProgressHUD* gpsHUD; @property (nonatomic, strong) MBProgressHUD* omemoHUD; @property (nonatomic, strong) UIBarButtonItem* callButton; @property (nonatomic, strong) NSMutableArray* messageList; @property (nonatomic, strong) UIDocumentPickerViewController* filePicker; @property (nonatomic, assign) BOOL sendLocation; // used for first request @property (nonatomic, strong) NSDate* lastMamDate; @property (nonatomic, assign) BOOL hardwareKeyboardPresent; @property (nonatomic, strong) xmpp* xmppAccount; @property (nonatomic, strong) NSLayoutConstraint* chatInputConstraintHWKeyboard; @property (nonatomic, strong) NSLayoutConstraint* chatInputConstraintSWKeyboard; //infinite scrolling @property (atomic) BOOL viewDidAppear; @property (atomic) BOOL viewIsScrolling; @property (atomic) BOOL isLoadingMam; @property (atomic) BOOL moreMessagesAvailable; @property (nonatomic, strong) UIButton* lastMsgButton; //SearchViewController, SearchResultViewController @property (nonatomic, strong) MLSearchViewController* searchController; @property (nonatomic, strong) NSMutableArray* searchResultMessageList; // Upload Queue @property (nonatomic, strong) NSMutableOrderedSet* uploadQueue; @property (nonatomic, strong) NSLayoutConstraint* uploadMenuConstraint; @property (nonatomic, strong) void (^editingCallback)(NSString* newBody); @property (nonatomic, strong) NSMutableSet* previewedIds; @property (atomic) BOOL isAudioMessage; @property (nonatomic) UILongPressGestureRecognizer* longGestureRecognizer; @property (nonatomic) UIView* audioRecoderInfoView; #define LAST_MSG_BUTTON_OFFSET 5 #define LAST_MSG_BUTTON_SIZE 40.0 @end @class HelperTools; @implementation chatViewController enum chatViewControllerSections { reloadBoxSection, messagesSection, chatViewControllerSectionCnt }; enum msgSentState { msgSent, msgErrorAfterSent, msgRecevied, msgDisplayed }; -(void) setupWithContact:(MLContact*) contact { self.contact = contact; [self setup]; } -(void) setup { self.hidesBottomBarWhenPushed = YES; NSDictionary* accountDict = [[DataLayer sharedInstance] detailsForAccount:self.contact.accountID]; if(accountDict) self.jid = [NSString stringWithFormat:@"%@@%@",[accountDict objectForKey:@"username"], [accountDict objectForKey:@"domain"]]; self.previewedIds = [NSMutableSet new]; _localMLContactCache = [[NSMutableDictionary alloc] init]; } #pragma mark - view lifecycle -(void) viewDidLoad { [super viewDidLoad]; if([[DataLayer sharedInstance] isContactInList:self.contact.contactJid forAccount:self.contact.accountID] == NO) { DDLogWarn(@"ChatView: Contact %@ is unkown", self.contact.contactJid); #ifdef IS_ALPHA @throw [NSException exceptionWithName:@"RuntimeException" reason:@"Contact is unkown - GUI error" userInfo:nil]; #endif } [self initNavigationBarItems]; [self setupDateObjects]; containerView = self.view; self.messageTable.scrollsToTop = YES; self.chatInput.scrollsToTop = NO; self.editingCallback = nil; self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; _isTyping = NO; self.hidesBottomBarWhenPushed=YES; self.chatInput.layer.borderColor = [UIColor lightGrayColor].CGColor; self.chatInput.layer.cornerRadius = 3.0f; self.chatInput.layer.borderWidth = 0.5f; self.chatInput.textContainerInset = UIEdgeInsetsMake(5, 0, 5, 0); self.messageTable.rowHeight = UITableViewAutomaticDimension; self.messageTable.estimatedRowHeight = UITableViewAutomaticDimension; #if TARGET_OS_MACCATALYST //does not become first responder like in iOS [self.view addSubview:self.inputContainerView]; [self.inputContainerView.leadingAnchor constraintEqualToAnchor:self.inputContainerView.superview.leadingAnchor].active = YES; [self.inputContainerView.bottomAnchor constraintEqualToAnchor:self.inputContainerView.superview.bottomAnchor].active = YES; [self.inputContainerView.trailingAnchor constraintEqualToAnchor:self.inputContainerView.superview.trailingAnchor].active = YES; self.tableviewBottom.constant += 20; #endif self.filePicker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:@[UTTypeItem]]; self.filePicker.allowsMultipleSelection = YES; self.filePicker.delegate = self; // Set max height of the chatInput (The chat should be still readable while the HW-Keyboard is active self.chatInputConstraintHWKeyboard = [NSLayoutConstraint constraintWithItem:self.chatInput attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationLessThanOrEqual toItem:nil attribute:NSLayoutAttributeHeight multiplier:1 constant:self.view.frame.size.height * 0.6]; self.chatInputConstraintSWKeyboard = [NSLayoutConstraint constraintWithItem:self.chatInput attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationLessThanOrEqual toItem:nil attribute:NSLayoutAttributeHeight multiplier:1 constant:self.view.frame.size.height * 0.4]; self.uploadMenuConstraint = [NSLayoutConstraint constraintWithItem:self.uploadMenuView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:0]; // Constant will be set through showUploadQueue [self.inputContainerView addConstraint:self.chatInputConstraintHWKeyboard]; [self.inputContainerView addConstraint:self.chatInputConstraintSWKeyboard]; [self.uploadMenuView addConstraint:self.uploadMenuConstraint]; [self setChatInputHeightConstraints:YES]; #if !TARGET_OS_MACCATALYST [self initAudioRecordButton]; #endif // setup refreshControl for infinite scrolling UIRefreshControl* refreshControl = [UIRefreshControl new]; [refreshControl addTarget:self action:@selector(loadOldMsgHistory:) forControlEvents:UIControlEventValueChanged]; refreshControl.attributedTitle = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"Loading more Messages from Server", @"")]; [self.messageTable setRefreshControl:refreshControl]; self.moreMessagesAvailable = YES; self.uploadQueue = [[NSMutableOrderedSet alloc] init]; [self.messageTable addInteraction:[[UIDropInteraction alloc] initWithDelegate:self]]; [self.inputContainerView addInteraction:[[UIDropInteraction alloc] initWithDelegate:self]]; #ifdef DISABLE_OMEMO NSMutableArray* rightBarButtons = [NSMutableArray new]; for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) if(entry.action != @selector(toggleEncryption:)) [rightBarButtons addObject:entry]; self.navigationItem.rightBarButtonItems = rightBarButtons; #endif [self updateCallButtonImage]; } -(void) updateCallButtonImage { if([HelperTools shouldProvideVoip]) { //this has to be done in the main thread because it's ui related //use reentrant dispatch to make sure we update the call button in one shot to not let it flicker //this does not matter if we aren't already in the main thread, hence the async dispatch [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ //these contact types can not be called if(self.contact.isMuc || self.contact.isSelfChat) { self.callButton = nil; //remove call button, if present NSMutableArray* rightBarButtons = [NSMutableArray new]; for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) if(entry.action != @selector(openCallScreen:)) [rightBarButtons addObject:entry]; self.navigationItem.rightBarButtonItems = rightBarButtons; return; } if(self.callButton == nil) { self.callButton = [UIBarButtonItem new]; [self.callButton setAction:@selector(openCallScreen:)]; } MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:self.contact]; if(activeCall != nil) self.callButton.image = [UIImage systemImageNamed:@"phone.connection.fill"]; else self.callButton.image = [UIImage systemImageNamed:@"phone.fill"]; //add the button to the bar button items if not already present BOOL present = NO; for(UIBarButtonItem* entry in self.navigationItem.rightBarButtonItems) if(entry.action == @selector(openCallScreen:)) present = YES; if(!present) { NSMutableArray* rightBarButtons = [self.navigationItem.rightBarButtonItems mutableCopy]; [rightBarButtons addObject:self.callButton]; self.navigationItem.rightBarButtonItems = rightBarButtons; } }]; } } -(void) initNavigationBarItems { UIView* cusView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 120, self.navigationController.navigationBar.frame.size.height)]; self.navBarIcon = [[UIImageView alloc] initWithFrame:CGRectMake(0, 7, 30, 30)]; self.navBarContactJid = [[UILabel alloc] initWithFrame:CGRectMake(38, 7, 200, 18)]; self.navBarLastInteraction = [[UILabel alloc] initWithFrame:CGRectMake(38, 26, 200, 12)]; self.navBarContactJid.font = [UIFont systemFontOfSize:15.0]; self.navBarLastInteraction.font = [UIFont systemFontOfSize:10.0]; [cusView addSubview:self.navBarIcon]; [cusView addSubview:self.navBarContactJid]; [cusView addSubview:self.navBarLastInteraction]; UITapGestureRecognizer* openContactDetailsTapAction = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(commandIPressed:)]; [cusView addGestureRecognizer:openContactDetailsTapAction]; UIBarButtonItem* customViewButtonWithMultipleItems = [[UIBarButtonItem alloc] initWithCustomView:cusView]; [customViewButtonWithMultipleItems setAction:@selector(commandIPressed:)]; // allow opening of contact details via voice over [customViewButtonWithMultipleItems setIsAccessibilityElement:YES]; [customViewButtonWithMultipleItems setAccessibilityTraits:UIAccessibilityTraitAllowsDirectInteraction]; [customViewButtonWithMultipleItems setAccessibilityLabel:self.navBarContactJid.text]; self.customHeader = customViewButtonWithMultipleItems; self.navigationItem.leftBarButtonItems = @[customViewButtonWithMultipleItems]; self.navigationItem.leftItemsSupplementBackButton = YES; } -(void) initLastMsgButton { unichar arrowSymbol = 0x2193; self.lastMsgButton = [UIButton new]; self.lastMsgButton.layer.cornerRadius = LAST_MSG_BUTTON_SIZE/2; self.lastMsgButton.layer.backgroundColor = [UIColor whiteColor].CGColor; [self.lastMsgButton setTitleColor:[UIColor grayColor] forState:UIControlStateNormal]; [self.lastMsgButton setTitle:[NSString stringWithCharacters:&arrowSymbol length:1] forState:UIControlStateNormal]; self.lastMsgButton.titleLabel.font = [UIFont systemFontOfSize:30.0]; self.lastMsgButton.layer.borderColor = [UIColor grayColor].CGColor; self.lastMsgButton.userInteractionEnabled = YES; [self.lastMsgButton setHidden:YES]; [self.inputContainerView addSubview:self.lastMsgButton]; [self positionLastMsgButtonAboveInputContainerView]; MLChatInputContainer* inputView = (MLChatInputContainer*) self.inputContainerView; inputView.chatInputActionDelegate = self; } -(void) initAudioRecordButton { self.longGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(recordMessageAudio:)]; self.longGestureRecognizer.minimumPressDuration = 0.8; [self.audioRecordButton addGestureRecognizer:self.longGestureRecognizer]; [self.sendButton setHidden:YES]; self.isAudioMessage = YES; } -(void) positionLastMsgButtonAboveInputContainerView { self.lastMsgButton.translatesAutoresizingMaskIntoConstraints = NO; [self.inputContainerView addConstraints:@[ [NSLayoutConstraint constraintWithItem:self.lastMsgButton attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:self.inputContainerView attribute:NSLayoutAttributeTrailing multiplier:1.0 constant:-LAST_MSG_BUTTON_OFFSET], [NSLayoutConstraint constraintWithItem:self.lastMsgButton attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.inputContainerView attribute:NSLayoutAttributeTop multiplier:1.0 constant:-LAST_MSG_BUTTON_OFFSET], ]]; [self.lastMsgButton.widthAnchor constraintEqualToConstant:LAST_MSG_BUTTON_SIZE].active = YES; [self.lastMsgButton.heightAnchor constraintEqualToConstant:LAST_MSG_BUTTON_SIZE].active = YES; } #pragma mark - ChatInputActionDelegage -(void) doScrollDownAction { [self scrollToBottomAnimated:YES]; } #pragma mark - SearchViewController -(void) initSearchViewControler { self.searchController = [[MLSearchViewController alloc] initWithSearchResultsController:nil]; [self.searchController setObscuresBackgroundDuringPresentation:NO]; self.searchController.searchResultDelegate = self; self.searchController.jid = self.jid; self.searchResultMessageList = [NSMutableArray new]; } -(void) initSearchButtonItem { UIBarButtonItem* seachButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSearch target:self action:@selector(showSeachButtonAction)]; NSMutableArray* rightBarButtons = [self.navigationItem.rightBarButtonItems mutableCopy]; [rightBarButtons addObject:seachButton]; self.navigationItem.rightBarButtonItems = rightBarButtons; } -(void) showSeachButtonAction { self.searchController.contact = self.contact; if(!(self.searchController.isViewLoaded && self.searchController.view.window)) [self presentViewController:self.searchController animated:NO completion:nil]; } -(void) dismissSearchViewControllerAction { [self.searchController dismissViewControllerAnimated:NO completion:nil]; } #pragma mark - SearchResultVCActionDelegate -(void) doGoSearchResultAction:(NSNumber*)nextDBId { NSNumber* messagePathIdx = [self.searchController getMessageIndexPathForDBId:nextDBId]; if (messagePathIdx != nil) { long nextPathIdx = [messagePathIdx longValue]; NSIndexPath* msgIdxPath = [NSIndexPath indexPathForRow:nextPathIdx inSection:messagesSection]; dispatch_async(dispatch_get_main_queue(), ^{ [self.messageTable scrollToRowAtIndexPath:msgIdxPath atScrollPosition:UITableViewScrollPositionMiddle animated:NO]; MLBaseCell* selectedCell = [self.messageTable cellForRowAtIndexPath:msgIdxPath]; UIColor* originColor = [selectedCell.backgroundColor copy]; selectedCell.backgroundColor = [UIColor lightGrayColor]; [UIView animateWithDuration:0.2 delay:0.2 options:UIViewAnimationOptionCurveLinear animations:^{ selectedCell.backgroundColor = originColor; } completion:nil]; }); } } -(void) doReloadHistoryForSearch { [self loadOldMsgHistory]; } - (void) doReloadActionForAllTableView { [self.messageTable reloadData]; } - (void) doGetMsgData { for (unsigned int idx = 0; idx < self.messageList.count; idx++) { MLMessage* msg = [self.messageList objectAtIndex:idx]; [self doSetMsgPathIdx:idx withDBId:msg.messageDBId]; } } -(void) doSetNotLoadingHistory { if (self.searchController.isActive) { self.searchController.isLoadingHistory = NO; [self.searchController setResultToolBar]; } [self doGetMsgData]; } -(void)doShowLoadingHistory:(NSString *)title { UIAlertController *loadingWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Hint", @"") message:title preferredStyle:UIAlertControllerStyleAlert]; [self presentViewController:loadingWarning animated:YES completion:^{ dispatch_queue_t queue = dispatch_get_main_queue(); dispatch_after(2.0, queue, ^{ [loadingWarning dismissViewControllerAnimated:YES completion:nil]; }); }]; } -(void) doSetMsgPathIdx:(NSInteger) pathIdx withDBId:(NSNumber *) messageDBId { if(messageDBId != nil) [self.searchController setMessageIndexPath:[NSNumber numberWithInteger:pathIdx] withDBId:messageDBId]; } -(BOOL) isContainKeyword:(NSNumber *) messageDBId { if([self.searchController getMessageIndexPathForDBId:messageDBId] != nil) return YES; return NO; } -(void) resetHistoryAttributeForCell:(MLBaseCell*) cell { if(!cell.messageBody.text) return; NSMutableAttributedString *defaultAttrString = [[NSMutableAttributedString alloc] initWithString:cell.messageBody.text]; NSInteger textLength = (cell.messageBody.text == nil) ? 0: cell.messageBody.text.length; NSRange defaultTextRange = NSMakeRange(0, textLength); [defaultAttrString addAttribute:NSBackgroundColorAttributeName value:[UIColor clearColor] range:defaultTextRange]; cell.messageBody.attributedText = defaultAttrString; cell.textLabel.backgroundColor = [UIColor clearColor]; } -(void) setChatInputHeightConstraints:(BOOL) hwKeyboardPresent { if(!self.chatInputConstraintHWKeyboard || !self.chatInputConstraintSWKeyboard) return; // activate / disable constraints depending on keyboard type self.chatInputConstraintHWKeyboard.active = hwKeyboardPresent; self.chatInputConstraintSWKeyboard.active = !hwKeyboardPresent; [self.inputContainerView layoutIfNeeded]; } -(void) handleForeGround { [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ @synchronized(self->_localMLContactCache) { [self->_localMLContactCache removeAllObjects]; } [self refreshData]; [self reloadTable]; }]; } -(void) openCallScreen:(id) sender { MLAssert(sender != nil || self.callButton != nil, @"We need at least one ui source (e.g. button) to base the popover controller upon!"); if(sender == nil) sender = self.callButton; MonalAppDelegate* appDelegate = (MonalAppDelegate *)[[UIApplication sharedApplication] delegate]; MLCall* activeCall = [appDelegate.voipProcessor getActiveCallWithContact:self.contact]; if(activeCall == nil && ![[DataLayer sharedInstance] checkCap:@"urn:xmpp:jingle-message:0" forUser:self.contact.contactJid onAccountID:self.contact.accountID]) { UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing Call Support", @"") message:NSLocalizedString(@"Your contact may not support calls. Your call might never reach its destination.", @"") preferredStyle:UIAlertControllerStyleActionSheet]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Try nevertheless", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self dismissViewControllerAnimated:YES completion:nil]; //now initiate call MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; [appDelegate.activeChats callContact:self.contact withUIKitSender:sender]; }]]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { [self dismissViewControllerAnimated:YES completion:nil]; }]]; UIPopoverPresentationController* popPresenter = [alert popoverPresentationController]; popPresenter.sourceItem = sender; [self presentViewController:alert animated:YES completion:nil]; } else { MonalAppDelegate* appDelegate = (MonalAppDelegate*)[[UIApplication sharedApplication] delegate]; [appDelegate.activeChats callContact:self.contact withUIKitSender:sender]; } } -(IBAction) toggleEncryption:(id) sender { if([HelperTools isContactBlacklistedForEncryption:self.contact]) return; #ifndef DISABLE_OMEMO if(self.contact.isEncrypted) { UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Disable encryption?", @"") message:NSLocalizedString(@"Do you really want to disable encryption for this contact?", @"") preferredStyle:UIAlertControllerStyleActionSheet]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Yes, deactivate encryption", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [MLChatViewHelper toggleEncryptionForContact:self.contact withSelf:self afterToggle:^() { [self displayEncryptionStateInUI]; }]; [self dismissViewControllerAnimated:YES completion:nil]; }]]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"No, keep encryption activated", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { [self dismissViewControllerAnimated:YES completion:nil]; }]]; UIPopoverPresentationController* popPresenter = [alert popoverPresentationController]; popPresenter.sourceItem = sender; [self presentViewController:alert animated:YES completion:nil]; } else [MLChatViewHelper toggleEncryptionForContact:self.contact withSelf:self afterToggle:^() { [self displayEncryptionStateInUI]; }]; #endif } -(void) observeValueForKeyPath:(NSString*) keyPath ofObject:(id) object change:(NSDictionary *) change context:(void*) context { if([keyPath isEqualToString:@"isEncrypted"] && object == self.contact) dispatch_async(dispatch_get_main_queue(), ^{ [self displayEncryptionStateInUI]; }); } -(void) displayEncryptionStateInUI { if(self.contact.isEncrypted) [self.navBarEncryptToggleButton setImage:[UIImage imageNamed:@"744-locked-received"]]; else [self.navBarEncryptToggleButton setImage:[UIImage imageNamed:@"745-unlocked"]]; //disable encryption button on unsupported muc types if(self.contact.isMuc && [self.contact.mucType isEqualToString:kMucTypeGroup] == NO) [self.navBarEncryptToggleButton setEnabled:NO]; //disable encryption button for special jids if([HelperTools isContactBlacklistedForEncryption:self.contact]) [self.navBarEncryptToggleButton setEnabled:NO]; } -(void) handleContactRemoved:(NSNotification*) notification { MLContact* contact = [notification.userInfo objectForKey:@"contact"]; if(self.contact && [self.contact isEqualToContact:contact]) { dispatch_async(dispatch_get_main_queue(), ^{ DDLogInfo(@"Closing chat view, contact was removed..."); [self.navigationController popToRootViewControllerAnimated:YES]; }); } } -(void) refreshContact:(NSNotification*) notification { @synchronized(_localMLContactCache) { [_localMLContactCache removeAllObjects]; } MLContact* contact = [notification.userInfo objectForKey:@"contact"]; if(self.contact && [self.contact isEqualToContact:contact]) [self updateUIElements]; } -(void) updateUIElements { if(self.contact.accountID == nil) return; NSString* jidLabelText = nil; BOOL sendButtonEnabled = NO; NSString* contactDisplayName = self.contact.contactDisplayName; if(!contactDisplayName) contactDisplayName = @""; //send button is always enabled, except if the account is permanently disabled sendButtonEnabled = YES; if(![[DataLayer sharedInstance] isAccountEnabled:self.contact.accountID]) sendButtonEnabled = NO; jidLabelText = contactDisplayName; if(self.contact.isMuc) { NSArray* members = [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:self.contact.contactJid forAccountID:self.xmppAccount.accountID]; NSInteger membercount = members.count; if([self.contact.mucType isEqualToString:kMucTypeGroup]) { NSMutableSet* memberSet = [NSMutableSet new]; for(NSDictionary* entry in members) { if(entry[@"participant_jid"] != nil) [memberSet addObject:entry[@"participant_jid"]]; if(entry[@"member_jid"] != nil) [memberSet addObject:entry[@"member_jid"]]; } membercount = memberSet.count; } if(membercount > 1) jidLabelText = [NSString stringWithFormat:@"%@ (%ld)", contactDisplayName, membercount - 1]; //don't count ourselves } // change text values dispatch_async(dispatch_get_main_queue(), ^{ self.navBarContactJid.text = jidLabelText; [self.customHeader setAccessibilityLabel:jidLabelText]; self.sendButton.enabled = sendButtonEnabled; [[MLImageManager sharedInstance] getIconForContact:self.contact withCompletion:^(UIImage *image) { self.navBarIcon.image = image; }]; [self updateCallButtonImage]; }); } -(void) updateUIElementsOnAccountChange:(NSNotification* _Nullable) notification { if(notification) { NSDictionary* userInfo = notification.userInfo; // Check if all objects of the notification are present NSString* accountID = [userInfo objectForKey:kAccountID]; NSNumber* accountState = [userInfo objectForKey:kAccountState]; // Only parse account changes for our current opened account if(accountID.intValue != self.xmppAccount.accountID.intValue) return; if(accountID && accountState) [self updateUIElements]; } else { [self updateUIElements]; } } -(void) stopLastInteractionTimer { @synchronized(self) { if(_cancelLastInteractionTimer) _cancelLastInteractionTimer(); _cancelLastInteractionTimer = nil; } } -(void) updateTypingTime:(NSDate* _Nullable) lastInteractionDate { DDLogVerbose(@"LastInteraction updateTime() called: %@", lastInteractionDate); NSString* lastInteractionString = @""; //unknown last interaction because not supported by any remote resource if(lastInteractionDate != nil) lastInteractionString = [HelperTools formatLastInteraction:lastInteractionDate]; dispatch_async(dispatch_get_main_queue(), ^{ self.navBarLastInteraction.text = lastInteractionString; }); @synchronized(self) { [self stopLastInteractionTimer]; // this timer will be called only if needed and makes sure the "last active: xx minutes ago" text gets updated every minute if(lastInteractionDate != nil && lastInteractionDate.timeIntervalSince1970 > 0) _cancelLastInteractionTimer = createTimer(60.0, ^{ [self updateTypingTime:lastInteractionDate]; }); } } -(void) updateNavBarLastInteractionLabel:(NSNotification*) notification { NSDate* lastInteractionDate = nil; NSString* jid = self.contact.contactJid; // use supplied data from notification... if(notification) { NSDictionary* data = notification.userInfo; NSString* notifcationAccountID = data[@"accountID"]; if(![jid isEqualToString:data[@"jid"]] || self.contact.accountID.intValue != notifcationAccountID.intValue) return; // ignore other accounts or contacts if([data[@"isTyping"] boolValue] == YES) { [self stopLastInteractionTimer]; dispatch_async(dispatch_get_main_queue(), ^{ self.navBarLastInteraction.text = NSLocalizedString(@"Typing...", @""); }); return; } // this is nil for a "not typing" (aka typing ended) notification or if no "urn:xmpp:idle:1" is supported by any devices of this contact lastInteractionDate = nilExtractor(data[@"lastInteraction"]); } // ...or load the latest interaction timestamp from db else // this is nil if no "urn:xmpp:idle:1" is supported by any devices of this contact lastInteractionDate = self.contact.lastInteractionTime; // make timestamp human readable (lastInteractionDate will be captured by this block and automatically used by our timer) [self updateTypingTime:lastInteractionDate]; } -(void) viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; //throw on empty contacts MLAssert(self.contact.contactJid != nil, @"can not open chat for empty contact jid"); MLAssert(self.contact.accountID != nil, @"can not open chat for empty account id"); NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self selector:@selector(handleNewMessage:) name:kMonalNewMessageNotice object:nil]; [nc addObserver:self selector:@selector(handleDeletedMessage:) name:kMonalDeletedMessageNotice object:nil]; [nc addObserver:self selector:@selector(handleSentMessage:) name:kMonalSentMessageNotice object:nil]; [nc addObserver:self selector:@selector(handleMessageError:) name:kMonalMessageErrorNotice object:nil]; [nc addObserver:self selector:@selector(handleOmemoFetchStateUpdate:) name:kMonalOmemoFetchingStateUpdate object:nil]; [nc addObserver:self selector:@selector(dismissKeyboard:) name:UIApplicationDidEnterBackgroundNotification object:nil]; [nc addObserver:self selector:@selector(handleForeGround) name:kMonalRefresh object:nil]; [nc addObserver:self selector:@selector(keyboardDidShow:) name:UIKeyboardDidShowNotification object:nil]; [nc addObserver:self selector:@selector(keyboardDidHide:) name:UIKeyboardDidHideNotification object:nil]; [nc addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; [nc addObserver:self selector:@selector(keyboardWillDisappear:) name:UIKeyboardWillHideNotification object:nil]; [nc addObserver:self selector:@selector(handleReceivedMessage:) name:kMonalMessageReceivedNotice object:nil]; [nc addObserver:self selector:@selector(handleDisplayedMessage:) name:kMonalMessageDisplayedNotice object:nil]; [nc addObserver:self selector:@selector(handleFiletransferMessageUpdate:) name:kMonalMessageFiletransferUpdateNotice object:nil]; [nc addObserver:self selector:@selector(refreshContact:) name:kMonalContactRefresh object:nil]; [nc addObserver:self selector:@selector(handleContactRemoved:) name:kMonalContactRemoved object:nil]; [nc addObserver:self selector:@selector(updateUIElementsOnAccountChange:) name:kMonalAccountStatusChanged object:nil]; [nc addObserver:self selector:@selector(updateNavBarLastInteractionLabel:) name:kMonalLastInteractionUpdatedNotice object:nil]; [nc addObserver:self selector:@selector(handleBackgroundChanged) name:kMonalBackgroundChanged object:nil]; [nc addObserver:self selector:@selector(updateCallButtonImage) name:kMonalCallAdded object:nil]; [nc addObserver:self selector:@selector(updateCallButtonImage) name:kMonalCallRemoved object:nil]; self.viewDidAppear = NO; self.viewIsScrolling = YES; //stop editing (if there is some) [self stopEditing]; self.xmppAccount = self.contact.account; if(!self.xmppAccount) DDLogDebug(@"Disabled account detected"); [MLNotificationManager sharedInstance].currentContact = self.contact; [self handleForeGround]; [self updateUIElements]; [self updateNavBarLastInteractionLabel:nil]; [self displayEncryptionStateInUI]; [self handleBackgroundChanged]; self.placeHolderText.text = [NSString stringWithFormat:NSLocalizedString(@"Message from %@", @""), self.jid]; // Load message draft from db NSString* messageDraft = [[DataLayer sharedInstance] loadMessageDraft:self.contact.contactJid forAccount:self.contact.accountID]; if(messageDraft && [messageDraft length] > 0) { dispatch_async(dispatch_get_main_queue(), ^{ self.chatInput.text = messageDraft; self.placeHolderText.hidden = YES; }); } self.hardwareKeyboardPresent = YES; //default to YES and when keybaord will appears is called, this may be set to NO [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; // Set correct chatInput height constraints [self setChatInputHeightConstraints:self.hardwareKeyboardPresent]; [self tempfreezeAutoloading]; [self.contact addObserver:self forKeyPath:@"isEncrypted" options:NSKeyValueObservingOptionNew context:nil]; [self scrollToBottomAnimated:NO]; } -(void) viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [self checkOmemoSupportWithAlert:NO]; [self refreshCounter]; //init the floating last message button [self initLastMsgButton]; self.viewDidAppear = YES; [self initSearchViewControler]; } -(void) viewWillDisappear:(BOOL)animated { //stop editing (if there is some) [self stopEditing]; //stop audio recording, if currently running if(self->_isRecording) { [[MLAudioRecoderManager sharedInstance] stop:NO]; self->_isRecording = NO; } NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; [nc removeObserver:self]; @try { [self.contact removeObserver:self forKeyPath:@"isEncrypted"]; } @catch(id theException) { //do nothing } // Save message draft BOOL success = [self saveMessageDraft]; if(success) { // Update status message for contact to show current draft [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; } [super viewWillDisappear:animated]; [MLNotificationManager sharedInstance].currentContact = nil; [self sendChatState:NO]; [self stopLastInteractionTimer]; [_lastMsgButton removeFromSuperview]; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; if(self.messageTable.contentSize.height > self.messageTable.bounds.size.height) [self.messageTable setContentOffset:CGPointMake(0, self.messageTable.contentSize.height - self.messageTable.bounds.size.height) animated:NO]; } -(BOOL) saveMessageDraft { // Save message draft return [[DataLayer sharedInstance] saveMessageDraft:self.contact.contactJid forAccount:self.contact.accountID withComment:self.chatInput.text]; } -(void) dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; @try { [self.contact removeObserver:self forKeyPath:@"isEncrypted"]; } @catch(id theException) { //do nothing } [self stopLastInteractionTimer]; } -(void) handleBackgroundChanged { dispatch_async(dispatch_get_main_queue(), ^{ DDLogVerbose(@"Loading background image for %@", self.contact); self.backgroundImage.image = [[MLImageManager sharedInstance] getBackgroundFor:self.contact]; //use default background if this contact does not have its own if(self.backgroundImage.image == nil) self.backgroundImage.image = [[MLImageManager sharedInstance] getBackgroundFor:nil]; self.backgroundImage.hidden = self.backgroundImage.image == nil; DDLogVerbose(@"Background is now: %@", self.backgroundImage.image); }); } #pragma mark rotation -(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [self stopEditing]; [self.chatInput resignFirstResponder]; } #pragma mark gestures -(IBAction)dismissKeyboard:(id)sender { [self stopEditing]; [self saveMessageDraft]; [self.chatInput resignFirstResponder]; [self sendChatState:NO]; } #pragma mark message signals -(void) refreshCounter { if(self.navigationController.topViewController == self) { if(![self.contact isEqualToContact:[MLNotificationManager sharedInstance].currentContact]) return; if(![HelperTools isNotInFocus]) { //don't block the main thread while writing to the db (another thread could hold a write transaction already, slowing down the main thread) dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ //get list of unread messages NSArray* unread = [[DataLayer sharedInstance] markMessagesAsReadForBuddy:self.contact.contactJid andAccount:self.contact.accountID tillStanzaId:nil wasOutgoing:NO]; //publish MDS display marker and optionally send displayed marker for last unread message (XEP-0333) DDLogDebug(@"Sending MDS (and possibly XEP-0333 displayed marker) for messages: %@", unread); [self.xmppAccount sendDisplayMarkerForMessages:unread]; //now switch back to the main thread, we are reading only (and self.contact should only be accessed from the main thread) dispatch_async(dispatch_get_main_queue(), ^{ //remove notifications of all read messages (this will cause the MLNotificationManager to update the app badge, too) [[MLNotificationQueue currentQueue] postNotificationName:kMonalDisplayedMessagesNotice object:self.xmppAccount userInfo:@{@"messagesArray":unread}]; // update unread counter [self.contact updateUnreadCount]; //refresh contact in active contacts view [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; }); }); } else DDLogDebug(@"Not marking messages as read because we are still in background: %@ notInFokus: %@", bool2str([HelperTools isInBackground]), bool2str([HelperTools isNotInFocus])); } } -(void) refreshData { if(!self.contact.contactJid) return; NSMutableArray* messages = [[DataLayer sharedInstance] messagesForContact:self.contact.contactJid forAccount: self.contact.accountID]; NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUserUnreadMessages:self.contact.contactJid forAccount: self.contact.accountID]; if([unreadMsgCnt integerValue] == 0) self->_firstmsg = YES; if(!self.jid) return; //TODO: use a factory method for this!! MLMessage* unreadStatus = [MLMessage new]; unreadStatus.messageType = kMessageTypeStatus; unreadStatus.messageText = NSLocalizedString(@"Unread Messages Below", @""); unreadStatus.actualFrom = self.jid; unreadStatus.isMuc = self.contact.isMuc; NSInteger unreadPos = (NSInteger)messages.count - 1; while(unreadPos >= 0) { MLMessage* row = [messages objectAtIndex:unreadPos]; if(!row.unread) { unreadPos++; //move back down one break; } unreadPos--; //move up the list } if(unreadPos <= (NSInteger)messages.count - 1 && unreadPos > 0) { [messages insertObject:unreadStatus atIndex:unreadPos]; } self.messageList = messages; [self doSetNotLoadingHistory]; [self refreshCounter]; } #pragma mark - textview -(void) sendMessage:(NSString*) messageText withType:(NSString*) messageType { [self sendMessage:messageText andMessageID:nil withType:messageType]; } -(void) sendMessage:(nonnull NSString*) messageText andMessageID:(NSString*) messageID withType:(NSString*) messageType { DDLogVerbose(@"Sending message"); NSString* newMessageID = messageID ? messageID : [[NSUUID UUID] UUIDString]; //dont readd it, use the exisitng NSDictionary* accountDict = [[DataLayer sharedInstance] detailsForAccount:self.contact.accountID]; if(accountDict == nil) { DDLogError(@"AccountID %@ not found!", self.contact.accountID); return; } if(self.contact.contactJid == nil || [[DataLayer sharedInstance] isContactInList:self.contact.contactJid forAccount:self.contact.accountID] == NO) { DDLogError(@"Can not send message to unkown contact %@ on accountID %@ - GUI Error", self.contact.contactJid, self.contact.accountID); return; } if(!messageID && !messageType) { DDLogError(@"message id and type both cant be empty"); return; } if(!messageID) { [self addMessageto:self.contact.contactJid withMessage:messageText andId:newMessageID messageType:messageType mimeType:nil size:nil]; [[MLXMPPManager sharedInstance] sendMessage:messageText toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:NO messageId:newMessageID withCompletionHandler:nil]; [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; } else { //clean error because this seems to be a retry (to be filled again, if error persists) [[DataLayer sharedInstance] clearErrorOfMessageId:newMessageID]; for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) { // find msg that should be updated MLMessage* msg = [self.messageList objectAtIndex:(msgIdx - 1)]; if([msg.messageId isEqualToString:newMessageID]) { msg.errorType = @""; msg.errorReason = @""; } } [[MLXMPPManager sharedInstance] sendMessage:messageText toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:NO messageId:newMessageID withCompletionHandler:nil ]; } [[MLNotificationQueue currentQueue] postNotificationName:kMLMessageSentToContact object:self userInfo:@{@"contact":self.contact}]; } -(void) sendChatState:(BOOL) isTyping { if(!self.sendButton.enabled) { DDLogWarn(@"Account disabled, ignoring chatstate update"); return; } // Do not send when the user disabled the feature if(![[HelperTools defaultsDB] boolForKey: @"SendLastChatState"]) return; if(isTyping != _isTyping) //changed state? --> send typing notification { DDLogVerbose(@"Sending chatstate isTyping=%@", bool2str(isTyping)); [[MLXMPPManager sharedInstance] sendChatState:isTyping toContact:self.contact]; } //set internal state _isTyping = isTyping; //cancel old timer if existing if(_cancelTypingNotification) _cancelTypingNotification(); //start new timer if we are currently typing if(isTyping) _cancelTypingNotification = createTimer(5.0, (^{ //no typing interaction in 5 seconds? --> send out active chatstate (e.g. typing ended) if(self->_isTyping) { self->_isTyping = NO; DDLogVerbose(@"Sending chatstate isTyping=NO"); [[MLXMPPManager sharedInstance] sendChatState:NO toContact:self.contact]; } })); } -(void) resignTextView { [self tempfreezeAutoloading]; // Trim leading spaces NSString* cleanString = [self.chatInput.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; // Only send msg that have at least one character if(cleanString.length > 0) { // Reset chatInput -> remove draft from db so that macOS will show the newly sent message [self.chatInput setText:@""]; [self saveMessageDraft]; [self setSendButtonIconWithTextLength:0]; if(self.editingCallback) self.editingCallback(cleanString); else { // Send trimmed message NSString* lowercaseCleanString = [cleanString lowercaseString]; if([lowercaseCleanString rangeOfString:@" "].location == NSNotFound && [lowercaseCleanString hasPrefix:@"https://"]) [self sendMessage:cleanString withType:kMessageTypeUrl]; else [self sendMessage:cleanString withType:kMessageTypeText]; } } [self sendChatState:NO]; [self emptyUploadQueue]; } -(IBAction) sendMessageText:(id)sender { [self resignTextView]; } -(void) handleRecord:(BOOL) granted { if(granted) { if(!self->_isRecording) { DDLogInfo(@"Starting to record audio..."); [[MLAudioRecoderManager sharedInstance] setRecoderManagerDelegate:self]; [[MLAudioRecoderManager sharedInstance] start]; self->_isRecording = YES; } else { DDLogInfo(@"Stopping audio recording..."); [[MLAudioRecoderManager sharedInstance] stop:YES]; self->_isRecording = NO; } } else { dispatch_async(dispatch_get_main_queue(), ^{ UIAlertController *messageAlert =[UIAlertController alertControllerWithTitle:NSLocalizedString(@"Please Allow Audio Access", @"") message:NSLocalizedString(@"If you want to use audio message you will need to allow access in Settings-> Privacy-> Microphone.", @"") preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction *closeAction =[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { }]; [messageAlert addAction:closeAction]; [self presentViewController:messageAlert animated:YES completion:nil]; }); } } -(IBAction) record:(id) sender { dispatch_async(dispatch_get_main_queue(), ^{ DDLogInfo(@"Record button pressed..."); if(@available(iOS 17, macCatalyst 17.0, *)) { [AVAudioApplication requestRecordPermissionWithCompletionHandler:^(BOOL granted) { [self handleRecord:granted]; }]; } else { [[AVAudioSession sharedInstance] requestRecordPermission:^(BOOL granted) { [self handleRecord:granted]; }]; } }); } -(void) recordMessageAudio:(UILongPressGestureRecognizer*) gestureRecognizer { DDLogInfo(@"Gesture recognizer called..."); if(gestureRecognizer.state == UIGestureRecognizerStateBegan && _isRecording) { DDLogInfo(@"Long press began, aborting audio recording..."); [[MLAudioRecoderManager sharedInstance] stop:NO]; _isRecording = NO; } } -(BOOL) shouldPerformSegueWithIdentifier:(NSString*) identifier sender:(id) sender { return YES; } -(void) performSegueWithIdentifier:(NSString*) identifier sender:(id) sender { //this is needed to prevent segues invoked programmatically if([self shouldPerformSegueWithIdentifier:identifier sender:sender] == NO) return; if([identifier isEqualToString:@"showDetails"]) { UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails: self.contact]; [self presentViewController:detailsViewController animated:YES completion:^{}]; return; } [super performSegueWithIdentifier:identifier sender:sender]; } -(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender { [self sendChatState:NO]; } #pragma mark - doc picker -(IBAction) attachfile:(id) sender { [self stopEditing]; [self.chatInput resignFirstResponder]; [self presentViewController:self.filePicker animated:YES completion:nil]; return; } -(void) documentPicker:(UIDocumentPickerViewController*) controller didPickDocumentsAtURLs:(NSArray*) urls { DDLogDebug(@"Picked files at urls: %@", urls); if(urls.count == 0) return; for(NSURL* url in urls) { [url startAccessingSecurityScopedResource]; //call to stopAccessingSecurityScopedResource will be done in addUploadItemPreviewForItem [HelperTools addUploadItemPreviewForItem:url provider:nil andPayload:[@{ @"type": @"file", @"filename": [url lastPathComponent], @"data": [MLFiletransfer prepareFileUpload:url], } mutableCopy] withCompletionHandler:^(NSMutableDictionary* payload) { [self addToUIQueue:@[payload]]; }]; } } #pragma mark - location delegate -(void) locationManagerDidChangeAuthorization:(CLLocationManager*) manager { CLAuthorizationStatus gpsStatus = [manager authorizationStatus]; if(gpsStatus == kCLAuthorizationStatusAuthorizedAlways || gpsStatus == kCLAuthorizationStatusAuthorizedWhenInUse) { if(self.sendLocation) { self.sendLocation = NO; [self.locationManager requestLocation]; } } else if(gpsStatus == kCLAuthorizationStatusDenied || gpsStatus == kCLAuthorizationStatusRestricted) { // Display warning UIAlertController* gpsWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Missing permission", @"") message:NSLocalizedString(@"You did not grant Monal to access your location.", @"") preferredStyle:UIAlertControllerStyleAlert]; [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Ok", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { [gpsWarning dismissViewControllerAnimated:YES completion:nil]; }]]; [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; }]]; [self presentViewController:gpsWarning animated:YES completion:nil]; } } -(void) locationManager:(CLLocationManager*) manager didUpdateLocations:(NSArray*) locations { [self.locationManager stopUpdatingLocation]; // Only send geo message if gpsHUD is visible if(self.gpsHUD.hidden == YES) { return; } // Check last location CLLocation* gpsLoc = [locations lastObject]; if(gpsLoc == nil) { return; } self.gpsHUD.hidden = YES; // Send location [self sendMessage:[NSString stringWithFormat:@"geo:%f,%f", gpsLoc.coordinate.latitude, gpsLoc.coordinate.longitude] withType:kMessageTypeGeo]; } - (void) locationManager:(CLLocationManager*) manager didFailWithError:(NSError*) error { DDLogError(@"Error while fetching location %@", error); } -(void) makeLocationManager { if(self.locationManager == nil) { self.locationManager = [CLLocationManager new]; self.locationManager.desiredAccuracy = kCLLocationAccuracyBest; self.locationManager.delegate = self; } } -(void) displayGPSHUD { // Setup HUD if(!self.gpsHUD) { self.gpsHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; self.gpsHUD.removeFromSuperViewOnHide=NO; self.gpsHUD.label.text = NSLocalizedString(@"GPS", @""); self.gpsHUD.detailsLabel.text = NSLocalizedString(@"Waiting for GPS signal", @""); } // Display HUD self.gpsHUD.hidden = NO; // Trigger warning when no gps location was received dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 4 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ if(self.gpsHUD.hidden == NO) { // Stop locationManager & hide gpsHUD screen [self.locationManager stopUpdatingLocation]; self.gpsHUD.hidden = YES; // Display warning UIAlertController* gpsWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No GPS location received", @"") message:NSLocalizedString(@"Monal did not received a gps location. Please try again later.", @"") preferredStyle:UIAlertControllerStyleAlert]; [gpsWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Ok", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { [gpsWarning dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:gpsWarning animated:YES completion:nil]; } }); } -(PHPickerViewController*) generatePHPickerViewController { PHPickerConfiguration* phConf = [PHPickerConfiguration new]; phConf.selectionLimit = 0; phConf.filter = [PHPickerFilter anyFilterMatchingSubfilters:@[PHPickerFilter.imagesFilter, PHPickerFilter.videosFilter]]; PHPickerViewController* picker = [[PHPickerViewController alloc] initWithConfiguration:phConf]; picker.delegate = self; return picker; } #pragma mark - attachment picker -(void) showCameraPermissionWarning { UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Camera permissions missing", @"Camera permissions missing warning") message:NSLocalizedString(@"Monal is not allowed to access the camera", @"Camera permissions missing warning") preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction* defaultAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action) {}]; UIAlertAction* monalIosSettings = [UIAlertAction actionWithTitle:NSLocalizedString(@"Settings", @"Camera permissions missing warning") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; }]; [alert addAction:defaultAction]; [alert addAction:monalIosSettings]; [self presentViewController:alert animated:YES completion:nil]; } -(IBAction) attach:(id) sender { [self stopEditing]; [self.chatInput resignFirstResponder]; UIAlertController* actionControll = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Select Action", @"") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; // Check for http upload support if(!self.xmppAccount.connectionProperties.supportsHTTPUpload) { UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Error", @"") message:NSLocalizedString(@"This server does not appear to support HTTP file uploads (XEP-0363). Please ask the administrator to enable it.", @"") preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; return; } else { #if TARGET_OS_MACCATALYST UIAlertAction* fileAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Files", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [self attachfile:sender]; }]; [fileAction setValue:[[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; [actionControll addAction:fileAction]; #else UIImagePickerController* mediaPicker = [UIImagePickerController new]; mediaPicker.delegate = self; UIAlertAction* cameraAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Camera", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action __unused) { @try { mediaPicker.sourceType = UIImagePickerControllerSourceTypeCamera; mediaPicker.mediaTypes = @[UTTypeImage.identifier, UTTypeMovie.identifier]; switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) { case AVAuthorizationStatusAuthorized: { dispatch_async(dispatch_get_main_queue(), ^{ [self presentViewController:mediaPicker animated:YES completion:nil]; }); break; } case AVAuthorizationStatusNotDetermined: { [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) { if(granted == YES) { dispatch_async(dispatch_get_main_queue(), ^{ [self presentViewController:mediaPicker animated:YES completion:nil]; }); } else DDLogWarn(@"Camera access not granted. AV Permissions now set to denied"); }]; break; } case AVAuthorizationStatusDenied: case AVAuthorizationStatusRestricted: { DDLogWarn(@"Camera access denied"); [self showCameraPermissionWarning]; break; } } } @catch(id ex) { DDLogError(@"catched exception while opening camera: %@", ex); [self showCameraPermissionWarning]; } }]; UIAlertAction* photosAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Photos", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action __unused) { [self presentViewController:[self generatePHPickerViewController] animated:YES completion:nil]; }]; UIAlertAction* fileAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"File", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [self attachfile:sender]; }]; // Set image [cameraAction setValue:[[UIImage systemImageNamed:@"camera"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; [photosAction setValue:[[UIImage systemImageNamed:@"photo"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; [fileAction setValue:[[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; [actionControll addAction:cameraAction]; [actionControll addAction:photosAction]; [actionControll addAction:fileAction]; #endif } UIAlertAction* gpsAlert = [UIAlertAction actionWithTitle:NSLocalizedString(@"Send Location", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* _Nonnull action) { // GPS CLLocationManager* gpsManager = [CLLocationManager new]; CLAuthorizationStatus gpsStatus = [gpsManager authorizationStatus]; if(gpsStatus == kCLAuthorizationStatusAuthorizedAlways || gpsStatus == kCLAuthorizationStatusAuthorizedWhenInUse) { [self displayGPSHUD]; [self makeLocationManager]; [self.locationManager startUpdatingLocation]; } else if(gpsStatus == kCLAuthorizationStatusNotDetermined || gpsStatus == kCLAuthorizationStatusRestricted) { [self makeLocationManager]; self.sendLocation = YES; [self.locationManager requestWhenInUseAuthorization]; } else { UIAlertController *permissionAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Location Access Needed", @"") message:NSLocalizedString(@"Monal does not have access to your location. Please update the location access in your device's Privacy Settings.", @"") preferredStyle:UIAlertControllerStyleAlert]; [self presentViewController:permissionAlert animated:YES completion:nil]; [permissionAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action __unused) { [permissionAlert dismissViewControllerAnimated:YES completion:nil]; }]]; } }]; // Set image [gpsAlert setValue:[[UIImage systemImageNamed:@"location"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal] forKey:@"image"]; [actionControll addAction:gpsAlert]; [actionControll addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* _Nonnull action) { [actionControll dismissViewControllerAnimated:YES completion:nil]; }]]; actionControll.popoverPresentationController.sourceView = sender; [self presentViewController:actionControll animated:YES completion:nil]; } -(void) picker:(PHPickerViewController*) picker didFinishPicking:(NSArray*) results { [self dismissViewControllerAnimated:YES completion:nil]; for(PHPickerResult* userSelection in results) { DDLogDebug(@"Handling asset with identifier: %@", userSelection.assetIdentifier); NSItemProvider* provider = userSelection.itemProvider; MLAssert(provider != nil, @"Expected a NSItemProvider"); [HelperTools handleUploadItemProvider:provider withCompletionHandler:^(NSMutableDictionary* payload) { dispatch_async(dispatch_get_main_queue(), ^{ if(payload == nil || payload[@"error"] != nil) { DDLogError(@"Could not save payload for sending: %@", payload[@"error"]); NSString* message = NSLocalizedString(@"Monal was not able to send your attachment!", @""); if(payload[@"error"] != nil) message = [NSString stringWithFormat:NSLocalizedString(@"Monal was not able to send your attachment: %@", @""), [payload[@"error"] localizedDescription]]; UIAlertController* unknownItemWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not send", @"") message:message preferredStyle:UIAlertControllerStyleAlert]; [unknownItemWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Abort", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { [unknownItemWarning dismissViewControllerAnimated:YES completion:nil]; [self.extensionContext completeRequestReturningItems:@[] completionHandler:nil]; }]]; [self presentViewController:unknownItemWarning animated:YES completion:nil]; } else { DDLogDebug(@"Adding payload to UI upload queue: %@", payload); [self addToUIQueue:@[payload]]; } }); }]; } } -(void) imagePickerController:(UIImagePickerController*) picker didFinishPickingMediaWithInfo:(NSDictionary*) info { [self dismissViewControllerAnimated:YES completion:nil]; if(info[UIImagePickerControllerMediaType] == nil) return; if([info[UIImagePickerControllerMediaType] isEqualToString:UTTypeImage.identifier]) { UIImage* selectedImage = info[UIImagePickerControllerEditedImage]; if(!selectedImage) selectedImage = info[UIImagePickerControllerOriginalImage]; [self addToUIQueue:@[@{ @"type": @"image", @"preview": selectedImage, @"data": [MLFiletransfer prepareUIImageUpload:selectedImage], }]]; } else if([info[UIImagePickerControllerMediaType] isEqualToString:UTTypeMovie.identifier]) { NSURL* url = info[UIImagePickerControllerMediaURL]; [url startAccessingSecurityScopedResource]; //call to stopAccessingSecurityScopedResource will be done in addUploadItemPreviewForItem [HelperTools addUploadItemPreviewForItem:url provider:nil andPayload:[@{ @"type": @"audiovisual", @"filename": [url lastPathComponent], @"data": [MLFiletransfer prepareFileUpload:url], } mutableCopy] withCompletionHandler:^(NSMutableDictionary* payload) { [self addToUIQueue:@[payload]]; }]; } else { DDLogWarn(@"Created MediaType: %@ without handler", info[UIImagePickerControllerMediaType]); unreachable(); } } -(void) imagePickerControllerDidCancel:(UIImagePickerController*) picker { [self dismissViewControllerAnimated:YES completion:nil]; } #pragma mark - handling notfications -(void) reloadTable { if(self.messageTable.hasUncommittedUpdates) return; [self.messageTable reloadData]; } //only for messages going out -(MLMessage* _Nullable) addMessageto:(NSString *)to withMessage:(nonnull NSString *) message andId:(nonnull NSString *) messageId messageType:(nonnull NSString *) messageType mimeType:(NSString *) mimeType size:(NSNumber *) size { if(!self.jid || !message) { DDLogError(@"not ready to send messages"); return nil; } NSNumber* messageDBId = [[DataLayer sharedInstance] addMessageHistoryTo:to forAccount:self.contact.accountID withMessage:message actuallyFrom:(self.contact.isMuc ? self.contact.accountNickInGroup : self.jid) withId:messageId encrypted:self.contact.isEncrypted messageType:messageType mimeType:mimeType size:size]; if(messageDBId != nil) { DDLogVerbose(@"added message"); NSArray* msgList = [[DataLayer sharedInstance] messagesForHistoryIDs:@[messageDBId]]; if(![msgList count]) { DDLogError(@"Could not find msg for history ID %@!", messageDBId); return nil; } MLMessage* messageObj = msgList[0]; [self tempfreezeAutoloading]; //update message list in ui dispatch_async(dispatch_get_main_queue(), ^{ BOOL wasAtBottom = self->_isAtBottom; [self.messageTable performBatchUpdates:^{ if(!self.messageList) self.messageList = [NSMutableArray new]; [self.messageList addObject:messageObj]; NSInteger bottom = [self.messageList count]-1; if(bottom>=0) { NSIndexPath* path1 = [NSIndexPath indexPathForRow:bottom inSection:messagesSection]; [self->_messageTable insertRowsAtIndexPaths:@[path1] withRowAnimation:UITableViewRowAnimationNone]; } } completion:^(BOOL finished) { if(wasAtBottom) [self scrollToBottomAnimated:NO]; }]; }); // make sure its in active chats list if(_firstmsg == YES) { [[DataLayer sharedInstance] addActiveBuddies:to forAccount:self.contact.accountID]; _firstmsg = NO; } //create and donate interaction to allow for share suggestions [[MLNotificationManager sharedInstance] donateInteractionForOutgoingDBId:messageDBId]; return messageObj; } else DDLogError(@"failed to add message to history db"); return nil; } -(void) handleNewMessage:(NSNotification *)notification { DDLogVerbose(@"chat view got new message notice %@", notification.userInfo); MLMessage* message = [notification.userInfo objectForKey:@"message"]; if(!message) DDLogError(@"Notification without message"); if([message isEqualToContact:self.contact]) { dispatch_async(dispatch_get_main_queue(), ^{ BOOL wasAtBottom = self->_isAtBottom; if(!self.messageList) self.messageList = [NSMutableArray new]; //update already existent message for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) { // find msg that should be updated MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; if([msgInList.messageDBId intValue] == [message.messageDBId intValue]) { //update message in our list [msgInList updateWithMessage:message]; //update table entry NSIndexPath* indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; dispatch_async(dispatch_get_main_queue(), ^{ [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; }); return; } } [CATransaction begin]; [self.messageList addObject:message]; //do not insert based on delay timestamp because that would make it possible to fake history entries [self->_messageTable beginUpdates]; NSIndexPath *path1; NSInteger bottom = self.messageList.count-1; if(bottom >= 0) { path1 = [NSIndexPath indexPathForRow:bottom inSection:messagesSection]; [self->_messageTable insertRowsAtIndexPaths:@[path1] withRowAnimation:UITableViewRowAnimationBottom]; } [self->_messageTable endUpdates]; [CATransaction commit]; if (self.searchController.isActive) { [self doSetMsgPathIdx:bottom withDBId:message.messageDBId]; [self.searchController getSearchData:self.self.searchController.searchBar.text]; [self.searchController setResultToolBar]; } [self refreshCounter]; if(wasAtBottom) [self scrollToBottomAnimated:YES]; }); } } -(void) handleDeletedMessage:(NSNotification*) notification { NSDictionary* dic = notification.userInfo; MLMessage* msg = dic[@"message"]; DDLogDebug(@"Got deleted message notice for history id %ld and message id %@", (long)[msg.messageDBId intValue], msg.messageId); for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) { // find msg that should be deleted MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; if([msgInList.messageDBId intValue] == [msg.messageDBId intValue]) { //update message in our list [msgInList updateWithMessage:msg]; //update table entry NSIndexPath* indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; dispatch_async(dispatch_get_main_queue(), ^{ [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; }); break; } } } -(void) updateMsgState:(NSString *) messageId withEvent:(size_t) event withOptDic:(NSDictionary*) dic { NSIndexPath* indexPath; for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) { // find msg that should be updated MLMessage* msg = [self.messageList objectAtIndex:(msgIdx - 1)]; if([msg.messageId isEqualToString:messageId]) { // Set correct flags if(event == msgSent) { DDLogVerbose(@"got msgSent event for messageid: %@", messageId); msg.hasBeenSent = YES; } else if(event == msgRecevied) { DDLogVerbose(@"got msgRecevied event for messageid: %@", messageId); msg.hasBeenSent = YES; msg.hasBeenReceived = YES; } else if(event == msgDisplayed) { DDLogVerbose(@"got msgDisplayed event for messageid: %@", messageId); msg.hasBeenSent = YES; msg.hasBeenReceived = YES; msg.hasBeenDisplayed = YES; } else if(event == msgErrorAfterSent) { DDLogVerbose(@"got msgErrorAfterSent event for messageid: %@", messageId); //we don't want to show errors if the message has been received at least once if(!msg.hasBeenReceived) { msg.errorType = [dic objectForKey:@"errorType"]; msg.errorReason = [dic objectForKey:@"errorReason"]; //ping muc to self-heal cases where we aren't joined anymore without noticing it if(self.contact.isMuc) [self.xmppAccount.mucProcessor ping:self.contact.contactJid]; } } indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; //update table entry dispatch_async(dispatch_get_main_queue(), ^{ [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; }); break; } } } -(void) handleSentMessage:(NSNotification*) notification { XMPPMessage* msg = notification.userInfo[@"message"]; if([msg.toUser isEqualToString:self.contact.contactJid]) [self updateMsgState:msg.id withEvent:msgSent withOptDic:nil]; } -(void) handleMessageError:(NSNotification*) notification { NSDictionary* dic = notification.userInfo; if([dic[@"jid"] isEqualToString:self.contact.contactJid]) [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgErrorAfterSent withOptDic:dic]; } -(void) handleReceivedMessage:(NSNotification*) notification { NSDictionary *dic = notification.userInfo; if([dic[@"jid"] isEqualToString:self.contact.contactJid]) [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgRecevied withOptDic:nil]; } -(void) handleDisplayedMessage:(NSNotification*) notification { NSDictionary *dic = notification.userInfo; if([dic[@"message"] isEqualToContact:self.contact]) [self updateMsgState:[dic objectForKey:kMessageId] withEvent:msgDisplayed withOptDic:nil]; } -(void) handleFiletransferMessageUpdate:(NSNotification*) notification { NSDictionary* dic = notification.userInfo; MLMessage* msg = dic[@"message"]; DDLogDebug(@"Got filetransfer message update for history id %ld: %@ (%@)", (long)[msg.messageDBId intValue], msg.filetransferMimeType, msg.filetransferSize); NSIndexPath* indexPath; for(size_t msgIdx = [self.messageList count]; msgIdx > 0; msgIdx--) { // find msg that should be updated MLMessage* msgInList = [self.messageList objectAtIndex:(msgIdx - 1)]; if([msgInList.messageDBId intValue] == [msg.messageDBId intValue]) { //update message in our list (this will copy filetransferMimeType and filetransferSize fields) [msgInList updateWithMessage:msg]; //update table entry indexPath = [NSIndexPath indexPathForRow:(msgIdx - 1) inSection:messagesSection]; dispatch_async(dispatch_get_main_queue(), ^{ [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; }); break; } } } -(void) scrollToBottomIfNeeded { if(_isAtBottom) { //DDLogVerbose(@"Scrolling to bottom because needed: %@", [NSThread callStackSymbols]); [self scrollToBottomAnimated:NO]; } } -(void) scrollToBottomAnimated:(BOOL) animated { if(self.messageList.count == 0) return; monal_void_block_t scrollBlock = ^{ NSInteger bottom = [self.messageTable numberOfRowsInSection:messagesSection]; if(bottom > 0) { DDLogVerbose(@"Scrolling to bottom(%@): %@", bool2str(animated), [NSThread callStackSymbols]); NSIndexPath* path1 = [NSIndexPath indexPathForRow:bottom-1 inSection:messagesSection]; [self.messageTable scrollToRowAtIndexPath:path1 atScrollPosition:UITableViewScrollPositionBottom animated:animated]; self->_isAtBottom = YES; } [self refreshCounter]; }; if(animated) { DDLogVerbose(@"Registering timer for scrolling to bottom(%@): %@", bool2str(animated), [NSThread callStackSymbols]); if(_scrollToBottomTimer) _scrollToBottomTimer(); _scrollToBottomTimer = createQueuedTimer(0.1, dispatch_get_main_queue(), (^{ scrollBlock(); })); } else [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:scrollBlock]; } #pragma mark - date time -(void) setupDateObjects { self.destinationDateFormat = [NSDateFormatter new]; [self.destinationDateFormat setLocale:[NSLocale currentLocale]]; [self.destinationDateFormat setDoesRelativeDateFormatting:YES]; self.gregorian = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; NSDate* now =[NSDate date]; self.thisday = [self.gregorian components:NSCalendarUnitDay fromDate:now].day; self.thismonth = [self.gregorian components:NSCalendarUnitMonth fromDate:now].month; self.thisyear = [self.gregorian components:NSCalendarUnitYear fromDate:now].year; } -(NSString*) formattedDateWithSource:(NSDate *) sourceDate andPriorDate:(NSDate *) priorDate { NSString* dateString; if(sourceDate!=nil) { NSInteger msgday =[self.gregorian components:NSCalendarUnitDay fromDate:sourceDate].day; NSInteger msgmonth=[self.gregorian components:NSCalendarUnitMonth fromDate:sourceDate].month; NSInteger msgyear =[self.gregorian components:NSCalendarUnitYear fromDate:sourceDate].year; NSInteger priorDay = 0; NSInteger priorMonth = 0; NSInteger priorYear = 0; if(priorDate) { priorDay = [self.gregorian components:NSCalendarUnitDay fromDate:priorDate].day; priorMonth = [self.gregorian components:NSCalendarUnitMonth fromDate:priorDate].month; priorYear = [self.gregorian components:NSCalendarUnitYear fromDate:priorDate].year; } if (priorDate && ((priorDay != msgday) || (priorMonth != msgmonth) || (priorYear != msgyear)) ) { //divider, hide time [self.destinationDateFormat setTimeStyle:NSDateFormatterNoStyle]; // note: if it isnt the same day we want to show the full day [self.destinationDateFormat setDateStyle:NSDateFormatterMediumStyle]; dateString = [self.destinationDateFormat stringFromDate:sourceDate]; } } return dateString; } -(NSString*) formattedTimeStampWithSource:(NSDate *) sourceDate { NSString* dateString; if(sourceDate != nil) { [self.destinationDateFormat setDateStyle:NSDateFormatterNoStyle]; [self.destinationDateFormat setTimeStyle:NSDateFormatterShortStyle]; dateString = [self.destinationDateFormat stringFromDate:sourceDate]; } return dateString; } -(void) retry:(id) sender { NSInteger msgHistoryID = ((UIButton*) sender).tag; NSArray* msgArray = [[DataLayer sharedInstance] messagesForHistoryIDs:@[[NSNumber numberWithInteger:msgHistoryID]]]; if(![msgArray count]) { DDLogError(@"Called retry for non existing message with history id %ld", (long)msgHistoryID); return; } MLMessage* msg = msgArray[0]; DDLogDebug(@"Called retry for message with history id %ld: %@", (long)msgHistoryID, msg); UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Retry sending message?", @"") message:[NSString stringWithFormat:NSLocalizedString(@"This message failed to send (%@): %@", @""), msg.errorType, msg.errorReason] preferredStyle:UIAlertControllerStyleActionSheet]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Retry", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [self sendMessage:msg.messageText andMessageID:msg.messageId withType:nil]; //type not needed for messages already in history db //[self setMessageId:msg.messageId sent:YES]; // for the UI, db will be set in the notification }]]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { [self dismissViewControllerAnimated:YES completion:nil]; }]]; alert.popoverPresentationController.sourceView = sender; [self presentViewController:alert animated:YES completion:nil]; } #pragma mark - tableview datasource -(NSInteger) numberOfSectionsInTableView:(UITableView *)tableView { return chatViewControllerSectionCnt; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { switch (section) { case reloadBoxSection: return 1; break; case messagesSection: { return [self.messageList count]; break; } default: break; } return 0; } -(nullable __kindof UITableViewCell*) messageTableCellWithIdentifier:(NSString*) identifier andInbound:(BOOL) inboundDirection fromTable:(UITableView*) tableView { NSString* direction = @"In"; if(!inboundDirection) { direction = @"Out"; } NSString* fullIdentifier = [NSString stringWithFormat:@"%@%@Cell", identifier, direction]; return [tableView dequeueReusableCellWithIdentifier:fullIdentifier]; } -(void) tableView:(UITableView*) tableView willDisplayCell:(nonnull UITableViewCell *)cell forRowAtIndexPath:(nonnull NSIndexPath *)indexPath { if(indexPath.section == messagesSection && indexPath.row == 0) { if(self.moreMessagesAvailable && !self.viewIsScrolling) { self.viewIsScrolling = YES; //don't load the next messages immediately [self loadOldMsgHistory]; // Allow loading of more messages after a few seconds createTimer(8, (^{ self.viewIsScrolling = NO; })); } } } -(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NSIndexPath*) indexPath { if(indexPath.section == reloadBoxSection) { MLReloadCell* cell = (MLReloadCell*)[tableView dequeueReusableCellWithIdentifier:@"reloadBox" forIndexPath:indexPath]; #if TARGET_OS_MACCATALYST // "Pull" could be a bit misleading on a mac cell.reloadLabel.text = NSLocalizedString(@"Scroll down to load more messages", @"mac only string"); #endif // Remove selection style (if cell is pressed) cell.selectionStyle = UITableViewCellSelectionStyleNone; return cell; } MLBaseCell* cell; MLMessage* row; if((NSUInteger)indexPath.row < self.messageList.count) { row = [self.messageList objectAtIndex:indexPath.row]; } else { DDLogError(@"Attempt to access beyond bounds"); } //cut text after kMonalChatMaxAllowedTextLen chars to make the message cell work properly (too big texts don't render the text in the cell at all) NSString* messageText = row.messageText; MLAssert(messageText != nil, @"Message text must not be nil!", (@{@"row": nilWrapper(row)})); if([messageText length] > kMonalChatMaxAllowedTextLen) messageText = [NSString stringWithFormat:@"%@\n[...]", [messageText substringToIndex:kMonalChatMaxAllowedTextLen]]; BOOL inboundDir = row.inbound; if([row.messageType isEqualToString:kMessageTypeStatus]) { DDLogVerbose(@"got status cell cell: %@", messageText); cell = [tableView dequeueReusableCellWithIdentifier:@"StatusCell"]; cell.messageBody.text = messageText; cell.link = nil; cell.parent = self; return cell; } if(cell == nil && [row.messageType isEqualToString:kMessageTypeFiletransfer]) { DDLogVerbose(@"got filetransfer chat cell: %@ (%@)", row.filetransferMimeType, row.filetransferSize); NSDictionary* info = [MLFiletransfer getFileInfoForMessage:row]; if(![info[@"needsDownloading"] boolValue]) { DDLogVerbose(@"Filetransfer already downloaded: %@", info); cell = [self fileTransferCellCheckerWithInfo:info direction:inboundDir tableView:tableView andMsg:row]; } else if([info[@"needsDownloading"] boolValue]) { DDLogVerbose(@"Filetransfer needs downloading: %@", info); MLFileTransferDataCell* fileTransferCell = (MLFileTransferDataCell*)[self messageTableCellWithIdentifier:@"fileTransferCheckingData" andInbound:inboundDir fromTable:tableView]; NSString* fileSize = info[@"size"] ? info[@"size"] : @"0"; [fileTransferCell initCellForMessageId:row.messageDBId andFilename:info[@"filename"] andMimeType:info[@"mimeType"] andFileSize:fileSize.longLongValue]; cell = fileTransferCell; } } if(cell == nil && [row.messageType isEqualToString:kMessageTypeUrl] && [[HelperTools defaultsDB] boolForKey:@"ShowURLPreview"]) { DDLogVerbose(@"got link preview cell: %@", messageText); MLLinkCell* toreturn = (MLLinkCell*)[self messageTableCellWithIdentifier:@"link" andInbound:inboundDir fromTable: tableView]; NSString* cleanLink = [messageText stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; NSArray* parts = [cleanLink componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; toreturn.link = parts[0]; row.url = [NSURL URLWithString:toreturn.link]; toreturn.messageBody.text = toreturn.link; toreturn.messageHistoryId = row.messageDBId; if(row.previewText != nil || row.previewImage != nil) { if((row.previewText == nil || row.previewText.length == 0) && (row.previewImage == nil || row.previewImage.absoluteString.length == 0)) { DDLogWarn(@"Not showing preview for %@, preview unavailable: row.previewText=%@, row.previewImage=%@", messageText, row.previewText, row.previewImage); toreturn = nil; //no preview available: use default MLChatCell for this } else { DDLogVerbose(@"Using db cached preview for %@", toreturn.link); toreturn.imageUrl = row.previewImage; toreturn.messageTitle.text = row.previewText; [toreturn loadImageWithCompletion:^{}]; } } else { DDLogVerbose(@"Loading link preview for %@", toreturn.link); [self loadPreviewWithUrlForRow:indexPath withResultHandler:^{ DDLogVerbose(@"Reloading row for preview: %@", messageText); [[DataLayer sharedInstance] setMessageId:row.messageId previewText:[row.previewText copy] andPreviewImage:[row.previewImage.absoluteString copy]]; //reload cells dispatch_async(dispatch_get_main_queue(), ^{ [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; }); }]; } cell = toreturn; } if(cell == nil && [row.messageType isEqualToString:kMessageTypeGeo]) { DDLogVerbose(@"got geo cell: %@", messageText); // Parse latitude and longitude NSError* error = NULL; NSRegularExpression* geoRegex = [NSRegularExpression regularExpressionWithPattern:geoPattern options:NSRegularExpressionCaseInsensitive error:&error]; if(error != NULL) { DDLogError(@"Error while loading geoPattern"); } NSTextCheckingResult* geoMatch = [geoRegex firstMatchInString:messageText options:0 range:NSMakeRange(0, [messageText length])]; if(geoMatch.numberOfRanges > 0) { NSRange latitudeRange = [geoMatch rangeAtIndex:1]; NSRange longitudeRange = [geoMatch rangeAtIndex:2]; NSString* latitude = [messageText substringWithRange:latitudeRange]; NSString* longitude = [messageText substringWithRange:longitudeRange]; // Display inline map if([[HelperTools defaultsDB] boolForKey: @"ShowGeoLocation"]) { MLChatMapsCell* mapsCell = (MLChatMapsCell*)[self messageTableCellWithIdentifier:@"maps" andInbound:inboundDir fromTable: tableView]; // Set lat / long used for map view and pin mapsCell.latitude = [latitude doubleValue]; mapsCell.longitude = [longitude doubleValue]; [mapsCell loadCoordinatesWithCompletion:^{}]; cell = mapsCell; } else { // Default to text cell cell = [self messageTableCellWithIdentifier:@"text" andInbound:inboundDir fromTable: tableView]; NSMutableAttributedString* geoString = [[NSMutableAttributedString alloc] initWithString:messageText]; [geoString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:[geoMatch rangeAtIndex:0]]; cell.messageBody.attributedText = geoString; NSInteger zoomLayer = 15; cell.link = [NSString stringWithFormat:@"https://www.openstreetmap.org/?mlat=%@&mlon=%@&zoom=%ldd", latitude, longitude, zoomLayer]; } } else { DDLogWarn(@"msgs of type kMessageTypeGeo should contain a geo location"); } } if(cell == nil) { DDLogVerbose(@"got normal text cell: %@", messageText); // Use default text cell cell = (MLChatCell*)[self messageTableCellWithIdentifier:@"text" andInbound:inboundDir fromTable: tableView]; //make sure everything is set to defaults cell.bubbleImage.hidden=NO; UIFont* originalFont = [UIFont systemFontOfSize:17.0f]; [cell.messageBody setFont:originalFont]; // Check if message contains a url NSString* lowerCase = [messageText lowercaseString]; NSRange pos = [lowerCase rangeOfString:@"https://"]; if(pos.location == NSNotFound) { pos = [lowerCase rangeOfString:@"http://"]; } if(pos.location == NSNotFound) { pos = [lowerCase rangeOfString:@"xmpp:"]; } NSRange pos2; if(pos.location != NSNotFound) { NSString* urlString = [messageText substringFromIndex:pos.location]; pos2 = [urlString rangeOfString:@" "]; if(pos2.location == NSNotFound) { pos2 = [urlString rangeOfString:@">"]; } if(pos2.location != NSNotFound) { urlString = [urlString substringToIndex:pos2.location]; } NSArray* parts = [urlString componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; cell.link = parts[0]; if(cell.link) { NSMutableAttributedString *formattedString = [[NSMutableAttributedString alloc] initWithString:messageText]; [formattedString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(pos.location, cell.link.length)]; cell.messageBody.text = nil; cell.messageBody.attributedText= formattedString; } } else // Default case { if(row.retracted) { NSString* stringToAttribute = NSLocalizedString(@"This message got retracted", @""); UIFont* italicFont = [UIFont italicSystemFontOfSize:cell.messageBody.font.pointSize]; NSMutableAttributedString* attributedMsgString = [[NSMutableAttributedString alloc] initWithString:stringToAttribute]; [attributedMsgString addAttribute:NSFontAttributeName value:italicFont range:NSMakeRange(0, stringToAttribute.length)]; [cell.messageBody setAttributedText:attributedMsgString]; } else if([MLEmoji containsEmojiWithText:messageText]) { UIFont* originalFont = [UIFont systemFontOfSize:cell.messageBody.font.pointSize*3]; [cell.messageBody setFont:originalFont]; [cell.messageBody setAttributedText:nil]; [cell.messageBody setText:messageText]; cell.bubbleImage.hidden=YES; } else if([messageText hasPrefix:@"/me "]) { UIFont* italicFont = [UIFont italicSystemFontOfSize:cell.messageBody.font.pointSize]; NSMutableAttributedString* attributedMsgString = [[MLXEPSlashMeHandler sharedInstance] attributedStringSlashMeWithMessage:row andFont:italicFont]; [cell.messageBody setAttributedText:attributedMsgString]; } else { // Reset attributes UIFont* originalFont = [UIFont systemFontOfSize:cell.messageBody.font.pointSize]; [cell.messageBody setFont:originalFont]; [cell.messageBody setAttributedText:nil]; [cell.messageBody setText:messageText]; } cell.link = nil; } } MLMessage* priorRow = nil; if(indexPath.row > 0) priorRow = [self.messageList objectAtIndex:indexPath.row-1]; // Only display names for groups BOOL hideName = YES; if(self.contact.isMuc) { if([kMucTypeGroup isEqualToString:self.contact.mucType] && row.participantJid) hideName = (priorRow != nil && [priorRow.participantJid isEqualToString:row.participantJid]); else hideName = (priorRow != nil && [priorRow.actualFrom isEqualToString:row.actualFrom]); //((MLMessage*)row).contactDisplayName will automatically use row.actualFrom as fallback for group-type mucs //if no roster name or XEP-0172 nickname could be found and always use row.actualFrom for channel-type mucs cell.name.text = hideName == YES ? nil : row.contactDisplayName; } // remove hidden text for better constraints if(hideName == YES) cell.name.text = nil; cell.name.hidden = hideName; if(row.hasBeenDisplayed) cell.messageStatus.text = kDisplayed; else if(row.hasBeenReceived) cell.messageStatus.text = kReceived; else if(row.hasBeenSent) cell.messageStatus.text = kSent; else cell.messageStatus.text = kSending; cell.messageHistoryId = row.messageDBId; BOOL newSender = NO; if(indexPath.row > 0) { if(priorRow.inbound != row.inbound) newSender = YES; } cell.date.text = [self formattedTimeStampWithSource:row.timestamp]; cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.dividerDate.text = [self formattedDateWithSource:row.timestamp andPriorDate:priorRow.timestamp]; // Do not hide the lockImage if the message was encrypted cell.lockImage.hidden = !row.encrypted; // Set correct layout in/Outbound cell.outBound = !inboundDir; // Hide messageStatus on inbound messages cell.messageStatus.hidden = inboundDir; cell.parent = self; if(cell.outBound && ([row.errorType length] > 0 || [row.errorReason length] > 0) && !row.hasBeenReceived && row.hasBeenSent) { cell.messageStatus.text = NSLocalizedString(@"Error", @""); cell.deliveryFailed = YES; } [cell updateCellWithNewSender:newSender]; if(!cell.link) [self resetHistoryAttributeForCell:cell]; if(self.searchController.isActive && row.messageDBId) { if([self.searchController isDBIdExistent:row.messageDBId]) { NSMutableAttributedString *attributedMsgString = [self.searchController doSearchKeyword:self.searchController.searchBar.text onText:messageText andInbound:inboundDir]; [cell.messageBody setAttributedText:attributedMsgString]; } } return cell; } -(MLContact*) getMLContactForJid:(NSString*) jid andAccount:(NSNumber*) accountID { NSString* cacheKey = [NSString stringWithFormat:@"%@|%@", jid, accountID]; @synchronized(_localMLContactCache) { if(_localMLContactCache[cacheKey]) return _localMLContactCache[cacheKey]; return _localMLContactCache[cacheKey] = [MLContact createContactFromJid:jid andAccountID:accountID]; } } #pragma mark - tableview delegate -(void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [self stopEditing]; [self.chatInput resignFirstResponder]; if(indexPath.section == reloadBoxSection) { [self loadOldMsgHistory]; } else if(indexPath.section == messagesSection) { MLBaseCell* cell = [tableView cellForRowAtIndexPath:indexPath]; if(cell.link) { if([cell respondsToSelector:@selector(openlink:)]) { DDLogVerbose(@"Trying to open link in chat cell"); [(MLChatCell *)cell openlink:self]; } else { dispatch_async(dispatch_get_main_queue(), ^{ NSDictionary* selectedItem = [MLFiletransfer getFileInfoForMessage:[self.messageList objectAtIndex:indexPath.row]]; NSMutableArray* allItems = [[DataLayer sharedInstance] allAttachmentsFromContact:self.contact.contactJid forAccount:self.contact.accountID]; UIViewController* imageViewer = [[SwiftuiInterface new] makeImageViewerForCurrentItem:selectedItem allItems:allItems]; imageViewer.modalPresentationStyle = UIModalPresentationOverFullScreen; [self presentViewController:imageViewer animated:YES completion:^{}]; }); } } } } -(void) closePhotos { [self.navigationController dismissViewControllerAnimated:YES completion:nil]; } #pragma mark tableview datasource - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { if(indexPath.section == reloadBoxSection) { return NO; } else { return YES; // for now } } - (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath { if(indexPath.section == reloadBoxSection) { return NO; } else { return YES; } } -(UISwipeActionsConfiguration*) tableView:(UITableView*) tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath*) indexPath { //stop editing (if there is some) on new swipe [self stopEditing]; //don't allow swipe actions for our reload box if(indexPath.section == reloadBoxSection) return [UISwipeActionsConfiguration configurationWithActions:@[]]; //do some sanity checks MLMessage* message; if((NSUInteger)indexPath.row < self.messageList.count) message = [self.messageList objectAtIndex:indexPath.row]; else { DDLogError(@"Attempt to access beyond bounds"); return [UISwipeActionsConfiguration configurationWithActions:@[]]; } if(message.messageDBId == nil) return [UISwipeActionsConfiguration configurationWithActions:@[]]; //configure swipe actions UIContextualAction* LMCEditAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Edit", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { [self.chatInput setText:message.messageText]; //we want to begin editing using the old message self.placeHolderText.hidden = YES; weakify(self); self.editingCallback = ^(NSString* newBody) { strongify(self); self.editingCallback = nil; if(newBody != nil) { message.messageText = newBody; [self.xmppAccount sendMessage:newBody toContact:self.contact isEncrypted:(self.contact.isEncrypted || message.encrypted) isUpload:NO andMessageId:[[NSUUID UUID] UUIDString] withLMCId:message.messageId]; [[DataLayer sharedInstance] updateMessageHistory:message.messageDBId withText:newBody]; [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; //update active chats if necessary [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; } else { self.placeHolderText.hidden = NO; [self.chatInput setText:@""]; } }; // We don't know yet if the editingCallback will complete successful. Pretend anyway return completionHandler(YES); }]; LMCEditAction.backgroundColor = UIColor.systemYellowColor; LMCEditAction.image = [[[UIImage systemImageNamed:@"pencil.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; UIContextualAction* quoteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Quote", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { NSMutableString* filteredString = [NSMutableString new]; //first of all: filter out already quoted text [message.messageText enumerateLinesUsingBlock:^(NSString* _Nonnull line, BOOL* _Nonnull stop) { if(line.length > 0 && [[line substringToIndex:1] isEqualToString:@">"]) return; [filteredString appendFormat:@"%@\n", line]; }]; NSMutableString* quoteString = [NSMutableString new]; //add datetime before quoting message if message is older than 15 minutes and 8 messages NSDate* timestamp = [[DataLayer sharedInstance] returnTimestampForQuote:message.messageDBId]; if(timestamp != nil) { [self.destinationDateFormat setDateStyle:NSDateFormatterMediumStyle]; [self.destinationDateFormat setTimeStyle:NSDateFormatterShortStyle]; [quoteString appendFormat:@"%@:\n", [self.destinationDateFormat stringFromDate:timestamp]]; } //then: make sure we quote only trimmed message contents [[filteredString stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet] enumerateLinesUsingBlock:^(NSString* _Nonnull line, BOOL* _Nonnull stop) { [quoteString appendFormat:@"> %@\n", line]; }]; //Append new empty line after quote [quoteString appendString:@"\n"]; //add already typed in text back in if(self.chatInput.text.length > 0) { [quoteString appendString:self.chatInput.text]; } self.chatInput.text = quoteString; self.placeHolderText.hidden = YES; return completionHandler(YES); }]; quoteAction.backgroundColor = UIColor.systemGreenColor; quoteAction.image = [[[UIImage systemImageNamed:@"quote.bubble.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; UIContextualAction* retractAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Retract", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { //only delete directly if we sent that message, try to moderate otherwise if(!message.inbound) { [self.xmppAccount retractMessage:message]; [[DataLayer sharedInstance] retractMessageHistory:message.messageDBId]; [message updateWithMessage:[[[DataLayer sharedInstance] messagesForHistoryIDs:@[message.messageDBId]] firstObject]]; //update table entry [self->_messageTable beginUpdates]; [self->_messageTable reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self->_messageTable endUpdates]; //update active chats if necessary [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; } else { //hardcode reason for now (change this when rewriting chatui using swiftui) [self.xmppAccount moderateMessage:message withReason:@"This message contains inappropriate content for this forum."]; } return completionHandler(YES); }]; retractAction.backgroundColor = UIColor.systemRedColor; retractAction.image = [[[UIImage systemImageNamed:@"arrow.uturn.backward.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; UIContextualAction* localDeleteAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Delete", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { [[DataLayer sharedInstance] deleteMessageHistoryLocally:message.messageDBId]; [self->_messageTable beginUpdates]; [self.messageList removeObjectAtIndex:indexPath.row]; [self->_messageTable deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationRight]; [self->_messageTable endUpdates]; //update active chats if necessary [[MLNotificationQueue currentQueue] postNotificationName:kMonalContactRefresh object:self.xmppAccount userInfo:@{@"contact": self.contact}]; return completionHandler(YES); }]; localDeleteAction.backgroundColor = UIColor.systemYellowColor; localDeleteAction.image = [[[UIImage systemImageNamed:@"trash.circle.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; UIContextualAction* copyAction = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Copy", @"Chat msg action") handler:^(UIContextualAction* action, UIView* sourceView, void (^completionHandler)(BOOL actionPerformed)) { UIPasteboard* pasteboard = [UIPasteboard generalPasteboard]; MLBaseCell* selectedCell = [self.messageTable cellForRowAtIndexPath:indexPath]; if([selectedCell isKindOfClass:[MLChatImageCell class]]) pasteboard.image = [(MLChatImageCell*)selectedCell getDisplayedImage]; else if([selectedCell isKindOfClass:[MLLinkCell class]]) pasteboard.URL = [NSURL URLWithString:((MLLinkCell*)selectedCell).link]; else pasteboard.string = message.messageText; return completionHandler(YES); }]; copyAction.backgroundColor = UIColor.systemGreenColor; copyAction.image = [[[UIImage systemImageNamed:@"doc.on.doc.fill"] imageWithHorizontallyFlippedOrientation] imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAutomatic]; //only allow editing for the 3 newest message && only on outgoing messages if((!message.inbound && [[DataLayer sharedInstance] checkLMCEligible:message.messageDBId encrypted:(message.encrypted || self.contact.isEncrypted) historyBaseID:nil]) && (!message.isMuc || (message.isMuc && message.stanzaId != nil)) && !message.retracted) return [UISwipeActionsConfiguration configurationWithActions:@[ quoteAction, copyAction, LMCEditAction, retractAction, ]]; else if(!message.inbound && [[DataLayer sharedInstance] checkLMCEligible:message.messageDBId encrypted:(message.encrypted || self.contact.isEncrypted) historyBaseID:nil] && !message.retracted) return [UISwipeActionsConfiguration configurationWithActions:@[ quoteAction, copyAction, LMCEditAction, localDeleteAction, ]]; //only allow retraction for outgoing messages or if we are the moderator of that muc //but only allow retraction in mucs if we already got the reflected stanzaid (or if this is an 1:1 chat) else if((!message.inbound || (self.contact.isMuc && [[[DataLayer sharedInstance] getOwnRoleInGroupOrChannel:self.contact] isEqualToString:kMucRoleModerator] && [[self.xmppAccount.mucProcessor getRoomFeaturesForMuc:self.contact.contactJid] containsObject:@"urn:xmpp:message-moderate:1"])) && (!message.isMuc || (message.isMuc && message.stanzaId != nil)) && !message.retracted) return [UISwipeActionsConfiguration configurationWithActions:@[ quoteAction, copyAction, retractAction, ]]; else return [UISwipeActionsConfiguration configurationWithActions:@[ quoteAction, copyAction, localDeleteAction, ]]; } -(MLBaseCell*) fileTransferCellCheckerWithInfo:(NSDictionary*)info direction:(BOOL)inDirection tableView:(UITableView*)tableView andMsg:(MLMessage*)row{ MLBaseCell* cell = nil; if(cell == nil && [info[@"mimeType"] hasPrefix:@"image/"]) { MLChatImageCell* imageCell = (MLChatImageCell*)[self messageTableCellWithIdentifier:@"image" andInbound:inDirection fromTable:tableView]; [imageCell initCellWithMLMessage:row]; cell = imageCell; } if(cell == nil && [info[@"mimeType"] hasPrefix:@"video/"]) { MLFileTransferVideoCell* videoCell = (MLFileTransferVideoCell*)[self messageTableCellWithIdentifier:@"fileTransferVideo" andInbound:inDirection fromTable:tableView]; NSString* videoStr = info[@"cacheFile"]; NSString* videoFileName = info[@"filename"]; [videoCell avplayerConfigWithUrlStr:videoStr andMimeType:info[@"mimeType"] fileName:videoFileName andVC:self]; cell = videoCell; } if(cell == nil && [info[@"mimeType"] hasPrefix:@"audio/"]) { //we may wan to make a new kind later but for now this is perfectly functional MLFileTransferVideoCell* audioCell = (MLFileTransferVideoCell*)[self messageTableCellWithIdentifier:@"fileTransferAudio" andInbound:inDirection fromTable:tableView]; NSString *audioStr = info[@"cacheFile"]; NSString *audioFileName = info[@"filename"]; [audioCell avplayerConfigWithUrlStr:audioStr andMimeType:info[@"mimeType"] fileName:audioFileName andVC:self]; cell = audioCell; } if(cell == nil) { MLFileTransferTextCell* textCell = (MLFileTransferTextCell*)[self messageTableCellWithIdentifier:@"fileTransferText" andInbound:inDirection fromTable:tableView]; NSString *fileSizeStr = info[@"size"]; long long fileSizeLongLongValue = fileSizeStr.longLongValue; NSString *readableFileSize = [NSByteCountFormatter stringFromByteCount:fileSizeLongLongValue countStyle:NSByteCountFormatterCountStyleFile]; NSString *hintStr = [NSString stringWithFormat:@"%@ %@", NSLocalizedString(@"Open", @""), info[@"filename"]]; NSString *fileCacheUrlStr = info[@"cacheFile"]; textCell.fileCacheUrlStr = fileCacheUrlStr; NSUInteger countOfMimtTypeComponent = [info[@"mimeType"] componentsSeparatedByString:@";"].count; NSString* fileMimeType = @""; NSString* fileCharSet = @""; NSString* fileEncodeName = @"utf-8"; if (countOfMimtTypeComponent > 1) { fileMimeType = [info[@"mimeType"] componentsSeparatedByString:@";"].firstObject; fileCharSet = [info[@"mimeType"] componentsSeparatedByString:@";"].lastObject; } else { fileMimeType = info[@"mimeType"]; } if (fileCharSet != nil && fileCharSet.length > 0) { fileEncodeName = [fileCharSet componentsSeparatedByString:@"="].lastObject; } textCell.fileMimeType = fileMimeType; textCell.fileName = info[@"filename"]; textCell.fileEncodeName = fileEncodeName; [textCell.fileTransferHint setText:hintStr]; [textCell.sizeLabel setText:readableFileSize]; textCell.openFileDelegate = self; cell = textCell; } return cell; } //dummy function needed to remove warnign -(void) openlink: (id) sender { } -(void) scrollViewDidScroll:(UIScrollView *)scrollView { // Only load old msgs if the view appeared if(!self.viewDidAppear) return; // get current scroll position (y-axis) CGFloat curOffset = scrollView.contentOffset.y; CGFloat bottomLength = scrollView.frame.size.height + curOffset; _isAtBottom = scrollView.contentSize.height <= bottomLength; if(_isAtBottom) [self.lastMsgButton setHidden:YES]; else [self.lastMsgButton setHidden:NO]; } -(void) loadOldMsgHistory { [self.messageTable.refreshControl beginRefreshing]; [self loadOldMsgHistory:self.messageTable.refreshControl]; } -(void) loadOldMsgHistory:(id) sender { // Load older messages from db NSMutableArray* oldMessages = nil; NSNumber* beforeId = nil; if(self.messageList.count > 0) beforeId = ((MLMessage*)[self.messageList objectAtIndex:0]).messageDBId; oldMessages = [[DataLayer sharedInstance] messagesForContact:self.contact.contactJid forAccount:self.contact.accountID beforeMsgHistoryID:beforeId]; if(!self.isLoadingMam && [oldMessages count] < kMonalBackscrollingMsgCount) { self.isLoadingMam = YES; //don't allow multiple parallel mam fetches //not all messages in history db have a stanzaId (messages sent by this monal instance won't have one for example) //--> search for the oldest message having a stanzaId and use that one NSString* oldestStanzaId; for(MLMessage* msg in oldMessages) if(msg.stanzaId) { DDLogVerbose(@"Found oldest stanzaId in messages returned from db: %@", msg.stanzaId); oldestStanzaId = msg.stanzaId; break; } if(!oldestStanzaId) { for(MLMessage* msg in self.messageList) { if(msg.stanzaId) { DDLogVerbose(@"Found oldest stanzaId in messages already displayed: %@", msg.stanzaId); oldestStanzaId = msg.stanzaId; break; } } } //history database for this contact is completely empty, use global last stanza id for this mam archive if(oldestStanzaId == nil) { if(self.contact.isMuc) oldestStanzaId = [[DataLayer sharedInstance] lastStanzaIdForMuc:self.contact.contactJid andAccount:self.contact.accountID]; else oldestStanzaId = [[DataLayer sharedInstance] lastStanzaIdForAccount:self.contact.accountID]; } //now load more (older) messages from mam DDLogVerbose(@"Loading more messages from mam before stanzaId %@", oldestStanzaId); weakify(self); [self.xmppAccount setMAMQueryMostRecentForContact:self.contact before:oldestStanzaId withCompletion:^(NSArray* _Nullable messages, NSString* _Nullable error) { dispatch_async(dispatch_get_main_queue(), ^{ strongify(self); if(!messages && !error) { //xmpp account got reconnected DDLogError(@"Got backscrolling mam error: nil (possible reconnect while querying)"); UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not fetch messages", @"") message:NSLocalizedString(@"The connection to the server was interrupted and no old messages could be fetched for this chat. Please try again later.", @"") preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; } else if(!messages) { NSString* errorText = error; if(!error) errorText = NSLocalizedString(@"Unknown error!", @""); DDLogError(@"Got backscrolling mam error: %@", errorText); UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not fetch messages", @"") message:[NSString stringWithFormat:NSLocalizedString(@"Could not fetch (all) old messages for this chat from your server archive. Please try again later. %@", @""), errorText] preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; } else { DDLogVerbose(@"Got backscrolling mam response: %lu", (unsigned long)[messages count]); if([messages count] == 0) { self.moreMessagesAvailable = NO; UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Finished fetching messages", @"") message:NSLocalizedString(@"All messages fetched successfully, there are no more left on the server!", @"") preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; } else [self insertOldMessages:[[messages reverseObjectEnumerator] allObjects]]; } //allow next mam fetch self.isLoadingMam = NO; if(sender) [(UIRefreshControl*)sender endRefreshing]; }); }]; } else if(!self.isLoadingMam && [oldMessages count] >= kMonalBackscrollingMsgCount) { if(sender) [(UIRefreshControl*)sender endRefreshing]; } //insert everything we got from the db so far if(oldMessages && [oldMessages count] > 0) { //use reverse order to insert messages from newest to oldest (bottom to top in chatview) [self insertOldMessages:[[oldMessages reverseObjectEnumerator] allObjects]]; } else { [self doSetNotLoadingHistory]; } } -(void) insertOldMessages:(NSArray*) oldMessages { dispatch_async(dispatch_get_main_queue(), ^{ if(!self.messageList) self.messageList = [NSMutableArray new]; CGSize sizeBeforeAddingMessages = [self->_messageTable contentSize]; // Insert old messages into messageTable NSMutableArray* indexArray = [NSMutableArray array]; for(size_t msgIdx = 0; msgIdx < [oldMessages count]; msgIdx++) { MLMessage* msg = [oldMessages objectAtIndex:msgIdx]; [self.messageList insertObject:msg atIndex:0]; NSIndexPath* newIndexPath = [NSIndexPath indexPathForRow:msgIdx inSection:messagesSection]; [indexArray addObject:newIndexPath]; } [self->_messageTable beginUpdates]; [self->_messageTable insertRowsAtIndexPaths:indexArray withRowAnimation:UITableViewRowAnimationNone]; // keep old position - scrolling may stop CGSize sizeAfterAddingMessages = [self->_messageTable contentSize]; CGPoint contentOffset = self->_messageTable.contentOffset; CGPoint newOffset = CGPointMake(contentOffset.x, contentOffset.y + sizeAfterAddingMessages.height - sizeBeforeAddingMessages.height); self->_messageTable.contentOffset = newOffset; [self->_messageTable endUpdates]; [self doSetNotLoadingHistory]; }); } -(BOOL) canBecomeFirstResponder { return YES; } -(UIView *) inputAccessoryView { return self.inputContainerView; } // Add new line to chatInput with 'shift + enter' -(void) shiftEnterKeyPressed:(UIKeyCommand*)keyCommand { if([self.chatInput isFirstResponder]) { // Get current cursor postion NSRange pos = [self.chatInput selectedRange]; // Insert \n self.chatInput.text = [self.chatInput.text stringByReplacingCharactersInRange:pos withString:@"\n"]; } } // Send message with 'enter' if chatInput is first repsonder -(void) enterKeyPressed:(UIKeyCommand*)keyCommand { if([self.chatInput isFirstResponder]) { [self resignTextView]; } } // Open contact details -(void) commandIPressed:(UIKeyCommand*)keyCommand { [self performSegueWithIdentifier:@"showDetails" sender:self]; } // Open search ViewController -(void) commandFPressed:(UIKeyCommand*)keyCommand { //[self showSeachButtonAction]; } // List of custom hardware key commands - (NSArray *)keyCommands { // shift + enter UIKeyCommand* shiftEnterKey = [UIKeyCommand keyCommandWithInput:@"\r" modifierFlags:UIKeyModifierShift action:@selector(shiftEnterKeyPressed:)]; // enter UIKeyCommand* enterKey = [UIKeyCommand keyCommandWithInput:@"\r" modifierFlags:0 action:@selector(enterKeyPressed:)]; UIKeyCommand* escapeKey = [UIKeyCommand keyCommandWithInput:UIKeyInputEscape modifierFlags:0 action:@selector(closePhotos)]; // prefer our key commands over the system defaults shiftEnterKey.wantsPriorityOverSystemBehavior = true; enterKey.wantsPriorityOverSystemBehavior = true; return @[ shiftEnterKey, enterKey, escapeKey, // command + i [UIKeyCommand keyCommandWithInput:@"i" modifierFlags:UIKeyModifierCommand action:@selector(commandIPressed:)], // command + f [UIKeyCommand keyCommandWithInput:@"f" modifierFlags:UIKeyModifierCommand action:@selector(commandFPressed:)] ]; } # pragma mark - Textview delegate functions -(void) textViewDidBeginEditing:(UITextView*) textView { [self scrollToBottomIfNeeded]; } - (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { BOOL shouldInsert = YES; // Notify that we are typing [self sendChatState:YES]; // Limit text length to kMonalChatMaxAllowedTextLen if([text isEqualToString:@""]) { shouldInsert &= YES; } else { shouldInsert &= (range.location + range.length < kMonalChatMaxAllowedTextLen); } shouldInsert &= ([textView.text length] + [text length] - range.length <= kMonalChatMaxAllowedTextLen); return shouldInsert; } - (void)textViewDidChange:(UITextView *)textView { if(textView.text.length > 0) self.placeHolderText.hidden = YES; else self.placeHolderText.hidden = NO; [self setSendButtonIconWithTextLength:[textView.text length]]; } -(void) setSendButtonIconWithTextLength:(NSUInteger)txtLength { #if TARGET_OS_MACCATALYST self.isAudioMessage = NO; [self.audioRecordButton setHidden:YES]; [self.sendButton setHidden:NO]; #else if ((txtLength == 0) && (self.uploadQueue.count == 0)) { self.isAudioMessage = YES; [self.audioRecordButton setHidden:NO]; [self.sendButton setHidden:YES]; } else { self.isAudioMessage = NO; [self.audioRecordButton setHidden:YES]; [self.sendButton setHidden:NO]; } #endif } #pragma mark - link preview -(void) loadPreviewWithUrlForRow:(NSIndexPath *) indexPath withResultHandler:(monal_void_block_t) resultHandler { MLMessage* row; if((NSUInteger)indexPath.row < self.messageList.count) row = [self.messageList objectAtIndex:indexPath.row]; else { DDLogError(@"Attempt to access beyond bounds"); return; } //prevent duplicated calls from cell animations (don't call resultHandler in this case because the resultHandler would reload the row) if([self.previewedIds containsObject:row.messageDBId]) { DDLogDebug(@"Not loading preview for already pending row: %@ in %@", row.messageDBId, self.previewedIds); return; } [self.previewedIds addObject:row.messageDBId]; row.previewText = @""; row.previewImage = [NSURL URLWithString:@""]; if(row.url) { DDLogVerbose(@"Fetching HTTP HEAD for %@...", row.url); NSMutableURLRequest* headRequest = [[NSMutableURLRequest alloc] initWithURL:row.url]; if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) headRequest.requiresDNSSECValidation = YES; headRequest.HTTPMethod = @"HEAD"; headRequest.cachePolicy = NSURLRequestReturnCacheDataElseLoad; NSURLSession* session = [HelperTools createEphemeralURLSession]; [[session dataTaskWithRequest:headRequest completionHandler:^(NSData* _Nullable data, NSURLResponse* _Nullable response, NSError* _Nullable error) { if(error != nil) { DDLogWarn(@"Loading preview HEAD for %@ failed: %@", row.url, error); resultHandler(); return; } NSDictionary* headers = ((NSHTTPURLResponse*)response).allHeaderFields; NSString* mimeType = [[headers objectForKey:@"Content-Type"] lowercaseString]; NSNumber* contentLength = [headers objectForKey:@"Content-Length"] ? [NSNumber numberWithInt:([[headers objectForKey:@"Content-Length"] intValue])] : @(-1); if(mimeType.length==0) { DDLogWarn(@"Loading preview HEAD for %@ failed: mimeType unkown", row.url); resultHandler(); return; } //preview images, too if([mimeType hasPrefix:@"image/"]) { DDLogVerbose(@"Now loading image preview data for: %@", row.url); row.previewText = [row.url lastPathComponent]; row.previewImage = row.url; resultHandler(); return; } if(![mimeType hasPrefix:@"text/"]) { DDLogWarn(@"Loading HEAD preview for %@ failed: mimeType not supported: %@", row.url, mimeType); resultHandler(); return; } //limit to 512KB of html if(contentLength.intValue > 524288) { DDLogWarn(@"Now loading preview HTML for %@ with byte range 0-512k...", row.url); [self downloadPreviewWithRow:indexPath usingByterange:YES andResultHandler:resultHandler]; return; } DDLogVerbose(@"Now loading preview for: %@", row.url); [self downloadPreviewWithRow:indexPath usingByterange:NO andResultHandler:resultHandler]; }] resume]; } else if(resultHandler) { DDLogWarn(@"Not loading HEAD preview for '%@': no url given!", row.url); resultHandler(); } } -(void) downloadPreviewWithRow:(NSIndexPath*) indexPath usingByterange:(BOOL) useByterange andResultHandler:(monal_void_block_t) resultHandler { MLMessage* row; if((NSUInteger)indexPath.row < self.messageList.count) row = [self.messageList objectAtIndex:indexPath.row]; else { DDLogError(@"Attempt to access beyond bounds"); return; } /** facebookexternalhit/1.1 */ DDLogVerbose(@"Fetching HTTP GET for %@...", row.url); NSMutableURLRequest* request = [[NSMutableURLRequest alloc] initWithURL:row.url]; if([[HelperTools defaultsDB] boolForKey: @"useDnssecForAllConnections"]) request.requiresDNSSECValidation = YES; [request setValue:@"facebookexternalhit/1.1" forHTTPHeaderField:@"User-Agent"]; //required on some sites for og tags e.g. youtube if(useByterange) [request setValue:@"bytes=0-524288" forHTTPHeaderField:@"Range"]; request.timeoutInterval = 10; NSURLSession* session = [HelperTools createEphemeralURLSession]; [[session dataTaskWithRequest:request completionHandler:^(NSData* _Nullable data, NSURLResponse* _Nullable response, NSError* _Nullable error) { if(error != nil) DDLogVerbose(@"preview fetching error: %@", error); else { NSString* body = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; MLOgHtmlParser* ogParser = nil; NSString* text = nil; NSURL* image = nil; if([body length] > 524288) body = [body substringToIndex:524288]; NSURL* baseURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@://%@%@", row.url.scheme, row.url.host, row.url.path]]; ogParser = [[MLOgHtmlParser alloc] initWithHtml:body andBaseUrl:baseURL]; if(ogParser != nil) { text = [ogParser getOgTitle]; image = [ogParser getOgImage]; } else DDLogError(@"Could not create OG parser!"); if((text != nil && text.length > 0) || (image != nil && image.absoluteString.length > 0)) { DDLogVerbose(@"Preview of %@: title=%@, image=%@", row.url, text, image); row.previewText = text; row.previewImage = image; } else { DDLogWarn(@"Preview of %@ is empty!", row.url); row.previewText = @""; row.previewImage = [NSURL URLWithString:@""]; } } [self.previewedIds removeObject:row.messageDBId]; resultHandler(); }] resume]; } #pragma mark - Keyboard - (void)keyboardWillDisappear:(NSNotification*) aNotification { [self setChatInputHeightConstraints:YES]; } - (void)keyboardDidShow:(NSNotification*)aNotification { //TODO grab animation info NSDictionary* info = [aNotification userInfo]; CGSize kbSize = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size; if(kbSize.height > 100) { //my inputbar +any other self.hardwareKeyboardPresent = NO; } UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, kbSize.height - 10, 0.0); self.messageTable.contentInset = contentInsets; self.messageTable.scrollIndicatorInsets = contentInsets; //this will be automatically called once the whole chat view is loaded (even if not showing a keyboard) [self scrollToBottomIfNeeded]; } - (void)keyboardDidHide:(NSNotification*)aNotification { [self saveMessageDraft]; [self sendChatState:NO]; UIEdgeInsets contentInsets = UIEdgeInsetsZero; self.messageTable.contentInset = contentInsets; self.messageTable.scrollIndicatorInsets = contentInsets; } - (void)keyboardWillShow:(NSNotification*)aNotification { [self setChatInputHeightConstraints:NO]; //TODO grab animation info // UIEdgeInsets contentInsets = UIEdgeInsetsZero; // self.messageTable.contentInset = contentInsets; // self.messageTable.scrollIndicatorInsets = contentInsets; } -(void) tempfreezeAutoloading { // Allow autoloading of more messages after a few seconds self.viewIsScrolling = YES; createTimer(1.5, (^{ self.viewIsScrolling = NO; })); } -(void) stopEditing { if(self.editingCallback) self.editingCallback(nil); //dismiss swipe action } -(void) checkOmemoSupportWithAlert:(BOOL) showWarning { #ifndef DISABLE_OMEMO if(self.xmppAccount && [[DataLayer sharedInstance] isAccountEnabled:self.xmppAccount.accountID]) { BOOL omemoDeviceForContactFound = NO; if(!self.contact.isMuc) omemoDeviceForContactFound = [self.xmppAccount.omemo knownDevicesForAddressName:self.contact.contactJid].count > 0; else { omemoDeviceForContactFound = NO; for(NSDictionary* participant in [[DataLayer sharedInstance] getMembersAndParticipantsOfMuc:self.contact.contactJid forAccountID:self.xmppAccount.accountID]) { if(participant[@"participant_jid"]) omemoDeviceForContactFound |= [self.xmppAccount.omemo knownDevicesForAddressName:participant[@"participant_jid"]].count > 0; else if(participant[@"member_jid"]) omemoDeviceForContactFound |= [self.xmppAccount.omemo knownDevicesForAddressName:participant[@"member_jid"]].count > 0; if(omemoDeviceForContactFound) break; } } if(!omemoDeviceForContactFound && self.contact.isEncrypted) { if(!self.contact.isMuc && [[HelperTools splitJid:self.contact.contactJid][@"host"] isEqualToString:@"cheogram.com"]) { // cheogram.com does not support OMEMO encryption as it is a PSTN gateway // --> disable it self.contact.isEncrypted = NO; [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; } else if(self.contact.isMuc && ![self.contact.mucType isEqualToString:kMucTypeGroup]) { // a channel type muc has OMEMO encryption enabled, but channels don't support encryption // --> disable it self.contact.isEncrypted = NO; [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; } else if(!self.contact.isMuc || (self.contact.isMuc && [self.contact.mucType isEqualToString:kMucTypeGroup])) { [self hideOmemoHUD]; if(showWarning) { DDLogWarn(@"Showing omemo not supported alert for: %@", self.contact); UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"No OMEMO keys found", @"") message:NSLocalizedString(@"This contact may not support OMEMO encrypted messages. Please try to enable encryption again in a few seconds, if you think this is wrong.", @"") preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Disable Encryption", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { // Disable encryption self.contact.isEncrypted = NO; [self updateUIElements]; [[DataLayer sharedInstance] disableEncryptForJid:self.contact.contactJid andAccountID:self.contact.accountID]; [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; } else { // async dispatch is needed to show hud on chat open // we won't do this twice, because the user won't be able to change isEncrypted to YES, // unless we have omemo devices for that contact dispatch_async(dispatch_get_main_queue(), ^{ [self showOmemoHUD]; }); // request omemo devicelist [self.xmppAccount.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:self.contact.contactJid]; } } } else [self hideOmemoHUD]; } #endif } -(void) showOmemoHUD { DDLogVerbose(@"Showing omemo HUD..."); if(!self.omemoHUD) { self.omemoHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; self.omemoHUD.removeFromSuperViewOnHide = YES; self.omemoHUD.label.text = NSLocalizedString(@"Loading OMEMO keys", @""); } else self.omemoHUD.hidden = NO; } -(void) hideOmemoHUD { DDLogVerbose(@"Hiding omemo HUD..."); self.omemoHUD.hidden = YES; } -(void) handleOmemoFetchStateUpdate:(NSNotification*) notification { xmpp* account = notification.object; MLContact* contact = [MLContact createContactFromJid:notification.userInfo[@"jid"] andAccountID:account.accountID]; if(self.contact && [self.contact isEqualToContact:contact]) { DDLogDebug(@"Got omemo fetching update: %@ --> %@", contact, notification.userInfo); if(!((NSNumber*)notification.userInfo[@"isFetching"]).boolValue) dispatch_async(dispatch_get_main_queue(), ^{ //recheck support and show alert if needed DDLogVerbose(@"Rechecking omemo support with alert, if needed..."); [self checkOmemoSupportWithAlert:YES]; }); } } -(void) showUploadHUD { if(!self.uploadHUD) { self.uploadHUD = [MBProgressHUD showHUDAddedTo:self.view animated:YES]; self.uploadHUD.removeFromSuperViewOnHide = YES; self.uploadHUD.label.text = NSLocalizedString(@"Uploading", @""); self.uploadHUD.detailsLabel.text = NSLocalizedString(@"Uploading file to server", @""); } else self.uploadHUD.hidden = NO; } -(void) hideUploadHUD { self.uploadHUD.hidden = YES; } -(void) showPotentialError:(NSError*) error { if(error) { DDLogError(@"Could not send attachment: %@", error); UIAlertController* alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not upload file", @"") message:[NSString stringWithFormat:@"%@", error.localizedDescription] preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; [self presentViewController:alert animated:YES completion:nil]; } } #pragma mark - MLFileTransferTextCell delegate -(void) showData:(NSString *)fileUrlStr withMimeType:(NSString *)mimeType andFileName:(NSString * _Nonnull)fileName andFileEncodeName:(NSString * _Nonnull)encodeName { MLFileTransferFileViewController *fileViewController = [MLFileTransferFileViewController new]; fileViewController.fileUrlStr = fileUrlStr; fileViewController.mimeType = mimeType; fileViewController.fileName = fileName; fileViewController.fileEncodeName = encodeName; [self presentViewController:fileViewController animated:NO completion:nil]; // [self.navigationController pushViewController:fileViewController animated:NO]; } #pragma mark - MLAudioRecoderManager delegate -(void) notifyStart { dispatch_async(dispatch_get_main_queue(), ^{ CGFloat infoHeight = self.inputContainerView.frame.size.height; CGFloat infoWidth = self.inputContainerView.frame.size.width; UIColor* labelBackgroundColor = self.inputContainerView.backgroundColor; self.audioRecoderInfoView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, infoWidth - 50, infoHeight)]; self.audioRecoderInfoView.backgroundColor = labelBackgroundColor; UILabel *audioTimeInfoLabel = [[UILabel alloc] initWithFrame:CGRectMake(5, 0, infoWidth - 50, infoHeight)]; [audioTimeInfoLabel setText:NSLocalizedString(@"Recording audio", @"")]; [self.audioRecoderInfoView addSubview:audioTimeInfoLabel]; [self.inputContainerView addSubview:self.audioRecoderInfoView]; [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateNormal]; [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateHighlighted]; [self.audioButton setTitleColor:[UIColor redColor] forState:UIControlStateSelected]; }); } -(void) notifyStop:(NSURL* _Nullable) fileURL { dispatch_async(dispatch_get_main_queue(), ^{ self->_isRecording = NO; [self.audioRecoderInfoView removeFromSuperview]; [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateNormal]; [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateHighlighted]; [self.audioButton setTitleColor:[UIColor blueColor] forState:UIControlStateSelected]; if(fileURL != nil) [self showUploadHUD]; }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSFileCoordinator* coordinator = [NSFileCoordinator new]; [coordinator coordinateReadingItemAtURL:fileURL options:NSFileCoordinatorReadingForUploading error:nil byAccessor:^(NSURL * _Nonnull newURL) { [MLFiletransfer uploadFile:newURL onAccount:self.xmppAccount withEncryption:self.contact.isEncrypted andCompletion:^(NSString* url, NSString* mimeType, NSNumber* size, NSError* error) { dispatch_async(dispatch_get_main_queue(), ^{ [self showPotentialError:error]; if(!error) { NSString* newMessageID = [[NSUUID UUID] UUIDString]; MLMessage* msg = [self addMessageto:self.contact.contactJid withMessage:url andId:newMessageID messageType:kMessageTypeFiletransfer mimeType:mimeType size:size]; [[MLXMPPManager sharedInstance] sendMessage:url toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:YES messageId:newMessageID withCompletionHandler:^(BOOL success, NSString *messageId) { DDLogInfo(@"File upload sent to contact..."); [MLFiletransfer hardlinkFileForMessage:msg]; //hardlink cache file if possible [self hideUploadHUD]; }]; } DDLogVerbose(@"upload done"); }); }]; }]; }); } -(void) updateCurrentTime:(NSTimeInterval) audioDuration { int durationMinutes = (int)audioDuration/60; int durationSeconds = (int)audioDuration - durationMinutes*60; for (UIView* subview in self.audioRecoderInfoView.subviews) { if([subview isKindOfClass:[UILabel class]]){ UILabel *infoLabel = (UILabel*)subview; [infoLabel setText:[NSString stringWithFormat:NSLocalizedString(@"%02d:%02d (long press to abort)", @""), durationMinutes, durationSeconds]]; [infoLabel setTextColor:[UIColor blackColor]]; } } } -(void) notifyResult:(BOOL)isSuccess error:(NSString*) errorMsg { dispatch_async(dispatch_get_main_queue(), ^{ self->_isRecording = NO; NSString* alertTitle = @""; if(isSuccess) { alertTitle = NSLocalizedString(@"Recode Success", @""); } else { alertTitle = [NSString stringWithFormat:@"%@%@", NSLocalizedString(@"Recode Fail:", @""), errorMsg]; } UIAlertController* audioRecoderAlert = [UIAlertController alertControllerWithTitle:alertTitle message:@"" preferredStyle:UIAlertControllerStyleAlert]; [self presentViewController:audioRecoderAlert animated:YES completion:^{ dispatch_queue_t queue = dispatch_get_main_queue(); dispatch_after(2.0, queue, ^{ [audioRecoderAlert dismissViewControllerAnimated:YES completion:nil]; }); }]; }); } # pragma mark - Upload Queue (Backend) -(void) handleMediaUploadCompletion:(NSString*) url withMime:(NSString*) mimeType withSize:(NSNumber*) size withError:(NSError*) error { monal_void_block_t handleNextUpload = ^{ if(self.uploadQueue.count > 0) { [self.uploadMenuView performBatchUpdates:^{ [self deleteQueueItemAtIndex:0]; } completion:^(BOOL finished){ [self emptyUploadQueue]; }]; } else { [self hideUploadQueue]; [self hideUploadHUD]; } }; DDLogVerbose(@"Now in upload completion"); [self showPotentialError:error]; if(!error) { NSString* newMessageID = [[NSUUID UUID] UUIDString]; MLMessage* msg = [self addMessageto:self.contact.contactJid withMessage:url andId:newMessageID messageType:kMessageTypeFiletransfer mimeType:mimeType size:size]; [[MLXMPPManager sharedInstance] sendMessage:url toContact:self.contact isEncrypted:self.contact.isEncrypted isUpload:YES messageId:newMessageID withCompletionHandler:^(BOOL success, NSString *messageId) { DDLogInfo(@"File upload sent to contact..."); [MLFiletransfer hardlinkFileForMessage:msg]; //hardlink cache file if possible handleNextUpload(); }]; DDLogInfo(@"upload done"); } else handleNextUpload(); } -(void) emptyUploadQueue { if(self.uploadQueue.count == 0) { [self hideUploadQueue]; [self hideUploadHUD]; return; } MLAssert(self.uploadQueue.count >= 1, @"upload queue contains less than 1 element"); [self showUploadHUD]; NSDictionary* payload = self.uploadQueue.firstObject; MLAssert([payload[@"type"] isEqualToString:@"image"] || [payload[@"type"] isEqualToString:@"file"] || [payload[@"type"] isEqualToString:@"contact"] || [payload[@"type"] isEqualToString:@"audiovisual"], @"Payload type must be of type image, file contact or audiovisual!", payload); DDLogVerbose(@"start dispatch"); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ $call(payload[@"data"], $ID(account, self.xmppAccount), $BOOL(encrypted, self.contact.isEncrypted), $ID(completion, (^(NSString* url, NSString* mimeType, NSNumber* size, NSError* error) { dispatch_async(dispatch_get_main_queue(), ^{ if(error != nil) [self handleMediaUploadCompletion:nil withMime:nil withSize:nil withError:error]; else [self handleMediaUploadCompletion:url withMime:mimeType withSize:size withError:error]; }); }))); }); } # pragma mark - Upload Queue (UI) -(void) showUploadQueue { self.uploadMenuConstraint.constant = 180; self.uploadMenuView.hidden = NO; } -(void) hideUploadQueue { [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; self.uploadMenuConstraint.constant = 1; // Can't set this to 0, because this will disable the view. If this were to happen, we would not use an accurate queue count if a user empties the queue and fills it afterwards. This is a hack to prevent this behaviour self.uploadMenuView.hidden = YES; } -(void) deleteQueueItemAtIndex:(NSUInteger) index { if(self.uploadQueue.count == 1) // Delete last object in queue { [self.uploadMenuView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index + 1 inSection:0]]]; // Delete '+' icon if queue is empty } [self.uploadQueue removeObjectAtIndex:index]; [self.uploadMenuView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index inSection:0]]]; } -(void) addToUIQueue:(NSArray*) newItems { dispatch_async(dispatch_get_main_queue(), ^{ if(self.uploadQueue.count == 0 && newItems.count > 0) // Queue was previously empty but will be filled now { // Force reload of view because this fails after the queue was emptied once otherwise. // The '+' cell may also not be in the collection view yet when this function is called. [CATransaction begin]; [UIView setAnimationsEnabled:NO]; [self showUploadQueue]; [self.uploadMenuView performBatchUpdates:^{ [self.uploadQueue addObjectsFromArray:newItems]; NSMutableArray* newInd = [[NSMutableArray alloc] initWithCapacity:newItems.count + 1]; for(NSUInteger i = 0; i <= newItems.count; i++) { newInd[i] = [NSIndexPath indexPathForItem:i inSection:0]; } DDLogVerbose(@"Inserting items at index paths: %@", newInd); [self.uploadMenuView insertItemsAtIndexPaths:newInd]; } completion:^(BOOL finished) { [CATransaction commit]; [UIView setAnimationsEnabled:YES]; [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; }]; } else { [self.uploadMenuView performBatchUpdates:^{ // Add all new elements NSUInteger start = self.uploadQueue.count; [self.uploadMenuView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:start inSection:0]]]; [self.uploadQueue addObjectsFromArray:newItems]; NSUInteger newElementsInSet = self.uploadQueue.count - start; NSMutableArray* newInd = [[NSMutableArray alloc] initWithCapacity:newElementsInSet]; for(NSUInteger i = 0; i < newElementsInSet; i++) { newInd[i] = [NSIndexPath indexPathForItem:start + i + 1 inSection:0]; } DDLogVerbose(@"Inserting items at index paths: %@", newInd); [self.uploadMenuView insertItemsAtIndexPaths:newInd]; } completion:^(BOOL finished) { [self.uploadMenuView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:self.uploadQueue.count inSection:0] atScrollPosition:UICollectionViewScrollPositionRight animated:YES]; [self setSendButtonIconWithTextLength:[self.chatInput.text length]]; }]; } }); } -(nonnull __kindof UICollectionViewCell*) collectionView:(nonnull UICollectionView*) collectionView cellForItemAtIndexPath:(nonnull NSIndexPath*) indexPath { // the '+' tile if((NSUInteger)indexPath.item == self.uploadQueue.count) return [self.uploadMenuView dequeueReusableCellWithReuseIdentifier:@"addToUploadQueueCell" forIndexPath:indexPath]; else { MLAssert(self.uploadQueue.count >= (NSUInteger)indexPath.item, @"index path is greater than count in upload queue"); NSDictionary* uploadItem = self.uploadQueue[indexPath.item]; // https://developer.apple.com/documentation/uikit/uicollectionview/1618063-dequeuereusablecellwithreuseiden?language=objc? MLUploadQueueCell* cell = (MLUploadQueueCell*) [self.uploadMenuView dequeueReusableCellWithReuseIdentifier:@"UploadQueueCell" forIndexPath:indexPath]; [cell initCellWithPreviewImage:uploadItem[@"preview"] filename:uploadItem[@"filename"] index:indexPath.item]; [cell setUploadQueueDelegate:self]; return cell; } } -(NSInteger) numberOfSectionsInCollectionView:(UICollectionView*) collectionView { return 1; } -(NSInteger)collectionView:(nonnull UICollectionView*) collectionView numberOfItemsInSection:(NSInteger) section { MLAssert(section == 0, @"section is only allowed to be zero"); return self.uploadQueue.count == 0 ? 0 : self.uploadQueue.count + 1; } -(void) notifyUploadQueueRemoval:(NSUInteger) index { if(index >= self.uploadQueue.count) return; [self.uploadMenuView performBatchUpdates:^{ [self deleteQueueItemAtIndex:index]; } completion:^(BOOL finished) { // Fix all indices accordingly for(NSUInteger i = 0; i < self.uploadQueue.count; i++) { MLUploadQueueCell* tmp = (MLUploadQueueCell*)[self.uploadMenuView cellForItemAtIndexPath:[NSIndexPath indexPathForItem:i inSection: 0]]; tmp.index = i; } // Don't show uploadMenuView if queue is empty again if(self.uploadQueue.count == 0) { [self hideUploadQueue]; } }]; } -(IBAction) addImageToUploadQueue { [self presentViewController:[self generatePHPickerViewController] animated:YES completion:nil]; } -(void) dropInteraction:(UIDropInteraction*) interaction performDrop:(id) session { for(UIDragItem* item in session.items) { NSItemProvider* provider = item.itemProvider; MLAssert(provider != nil, @"provider must not be nil"); MLAssert([provider hasItemConformingToTypeIdentifier:UTTypeItem.identifier], @"provider must supply item conforming to kUTTypeItem"); [HelperTools handleUploadItemProvider:provider withCompletionHandler:^(NSMutableDictionary* _Nullable payload) { dispatch_async(dispatch_get_main_queue(), ^{ if(payload == nil || payload[@"error"] != nil) { DDLogError(@"Could not save payload for sending: %@", payload[@"error"]); NSString* message = NSLocalizedString(@"Monal was not able to send your attachment!", @""); if(payload[@"error"] != nil) message = [NSString stringWithFormat:NSLocalizedString(@"Monal was not able to send your attachment: %@", @""), [payload[@"error"] localizedDescription]]; UIAlertController* unknownItemWarning = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Could not send", @"") message:message preferredStyle:UIAlertControllerStyleAlert]; [unknownItemWarning addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Abort", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { [unknownItemWarning dismissViewControllerAnimated:YES completion:nil]; [self.extensionContext completeRequestReturningItems:@[] completionHandler:nil]; }]]; [self presentViewController:unknownItemWarning animated:YES completion:nil]; } else [self addToUIQueue:@[payload]]; }); }]; } } -(UIDropProposal*) dropInteraction:(UIDropInteraction*) interaction sessionDidUpdate:(id) session { return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy]; } @end