another.im-ios/Monal/Classes/chatViewController.m

3628 lines
156 KiB
Mathematica
Raw Permalink Normal View History

2024-11-18 14:53:52 +00:00
//
// 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 <Monal-Swift.h>
#import <stdatomic.h>
#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()<ChatInputActionDelegage, UISearchControllerDelegate>
{
BOOL _isTyping;
monal_void_block_t _cancelTypingNotification;
monal_void_block_t _cancelLastInteractionTimer;
NSMutableDictionary<NSString*, MLContact*>* _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<MLMessage*>* 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<NSDictionary*>* 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<NSString*, MLContact*> 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<NSDictionary*> 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<chatViewController*> 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<chatViewController*> 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<UIViewControllerTransitionCoordinator>)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<MLMessage*>* 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<NSURL*>*) 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<CLLocation*>*) 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<PHPickerResult*>*) 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<NSString*, id>*) 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<UIKeyCommand *> *)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;
}
/**
<meta property="og:title" content="Nintendo recommits to “keep the business going” for 3DS">
<meta property="og:image" content="https://cdn.arstechnica.net/wp-content/uploads/2016/09/3DS_SuperMarioMakerforNintendo3DS_char_01-760x380.jpg">
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<NSDictionary*>*) 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<NSIndexPath*>* newInd = [[NSMutableArray<NSIndexPath*> 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<NSIndexPath*>* newInd = [[NSMutableArray<NSIndexPath*> 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<UIDropSession>) 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<UIDropSession>) session
{
return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy];
}
@end