blob: 3985cfdce46fe4ae7501b3fa825ce68c0c301eaa [file] [log] [blame]
/*
* Copyright (C) 2017-2021 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
* Author: Quentin Muret <quentin.muret@savoirfairelinux.com>
* Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
* Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com>
* Author: Alireza Toghiani Khorasgani alireza.toghiani@savoirfairelinux.com *
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import UIKit
import PhotosUI
import RxSwift
import Reusable
import SwiftyBeaver
import Photos
import MobileCoreServices
import SwiftUI
import RxRelay
enum ContextMenu: State {
case preview(message: MessageContentVM)
case forward(message: MessageContentVM)
case share(items: [Any])
case saveFile(url: URL)
case reply(message: MessageContentVM)
case delete(message: MessageContentVM)
case edit(message: MessageContentVM)
case scrollToReplyTarget(messageId: String)
}
enum DocumentPickerMode {
case picking
case saving
case none
}
// swiftlint:disable file_length
// swiftlint:disable type_body_length
class ConversationViewController: UIViewController,
UIImagePickerControllerDelegate, UINavigationControllerDelegate,
StoryboardBased, ViewModelBased, ContactPickerDelegate,
PHPickerViewControllerDelegate {
// MARK: StateableResponsive
let disposeBag = DisposeBag()
let log = SwiftyBeaver.self
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var spinnerView: UIView!
var viewModel: ConversationViewModel!
var isExecutingDeleteMessage: Bool = false
private var isLocationSharingDurationLimited: Bool {
return UserDefaults.standard.bool(forKey: limitLocationSharingDurationKey)
}
private var locationSharingDuration: Int {
return UserDefaults.standard.integer(forKey: locationSharingDurationKey)
}
@IBOutlet weak var currentCallButton: UIButton!
@IBOutlet weak var currentCallLabel: UILabel!
@IBOutlet weak var conversationInSyncLabel: UILabel!
@IBOutlet weak var scanButtonLeadingConstraint: NSLayoutConstraint!
@IBOutlet weak var callButtonHeightConstraint: NSLayoutConstraint!
var swiftUIViewAdded: Bool = false
var currentDocumentPickerMode: DocumentPickerMode = .none
let tapAction = BehaviorRelay<Bool>(value: false)
var screenTapRecognizer: UITapGestureRecognizer!
private lazy var locationManager: CLLocationManager = { return CLLocationManager() }()
func setIsComposing(isComposing: Bool) {
self.viewModel.setIsComposingMsg(isComposing: isComposing)
}
override func viewDidLoad() {
super.viewDidLoad()
self.configureNavigationBar()
self.setupUI()
self.setupBindings()
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil)
screenTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(screenTapped))
self.view.addGestureRecognizer(screenTapRecognizer)
}
@objc
func screenTapped() {
tapAction.accept(true)
}
private func addSwiftUIView() {
swiftUIViewAdded = true
let transferHelper = TransferHelper(dataTransferService: self.viewModel.dataTransferService,
conversationViewModel: self.viewModel)
let swiftUIModel = MessagesListVM(injectionBag: self.viewModel.injectionBag,
conversation: self.viewModel.conversation.value,
transferHelper: transferHelper,
bestName: self.viewModel.bestName,
screenTapped: tapAction.asObservable())
swiftUIModel.hideNavigationBar
.subscribe(onNext: { [weak self] (hide) in
guard let self = self else { return }
if self.navigationItem.rightBarButtonItems?.isEmpty == hide { return }
if hide {
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
self.navigationItem.titleView = UIView()
self.navigationItem.rightBarButtonItems = []
self.navigationItem.setHidesBackButton(true, animated: false)
} else {
self.configureNavigationBar()
self.setRightNavigationButtons()
self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
displayName: self.viewModel.displayName.value,
username: self.viewModel.userName.value)
self.updateNavigationBarShadow()
}
})
.disposed(by: self.disposeBag)
swiftUIModel.messagePanelState
.subscribe(onNext: { [weak self] (state) in
guard let self = self, let state = state as? MessagePanelState else { return }
switch state {
case .sendMessage(let content, let parentId):
self.viewModel.sendMessage(withContent: content, parentId: parentId)
case .sendPhoto:
self.takePicture()
case .editMessage(content: let content, messageId: let messageId):
self.viewModel.editMessage(content: content, messageId: messageId)
case .openGalery:
self.selectItemsFromPhotoLibrary()
case .shareLocation:
self.startLocationSharing()
case .recordAudio:
self.recordAudio()
case .recordVido:
self.recordVideo()
case .sendFile:
self.importDocument()
}
})
.disposed(by: self.disposeBag)
swiftUIModel.contextMenuState
.subscribe(onNext: { [weak self] (state) in
guard let self = self, let state = state as? ContextMenu else { return }
switch state {
case .preview(let message):
self.presentPreview(message: message)
case .forward(let message):
/*
Remove the tap gesture to ensure the contact selector
can receive taps. The tap gesture should be re-added
once the contact picker is dismissed.
*/
self.view.removeGestureRecognizer(self.screenTapRecognizer)
self.viewModel.slectContactsToShareMessage(message: message)
case .share(let items):
self.presentActivityControllerWithItems(items: items)
case .saveFile(let url):
self.saveFile(url: url)
default:
break
}
})
.disposed(by: self.disposeBag)
self.viewModel.conversationCreated
.observe(on: MainScheduler.instance)
.subscribe { [weak self, weak swiftUIModel] update in
guard let self = self, let swiftUIModel = swiftUIModel, update else { return }
swiftUIModel.conversation = self.viewModel.conversation.value
} onError: { _ in
}
.disposed(by: self.disposeBag)
let messageListView = MessagesListView(model: swiftUIModel)
let swiftUIView = UIHostingController(rootView: messageListView)
addChild(swiftUIView)
swiftUIView.view.frame = self.view.frame
self.view.addSubview(swiftUIView.view)
swiftUIView.view.translatesAutoresizingMaskIntoConstraints = false
swiftUIView.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0).isActive = true
swiftUIView.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
swiftUIView.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0).isActive = true
swiftUIView.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
swiftUIView.didMove(toParent: self)
self.view.backgroundColor = UIColor.systemBackground
self.view.sendSubviewToBack(swiftUIView.view)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {[weak self] in
self?.messagesLoadingFinished()
}
}
@objc
private func applicationWillResignActive() {
self.viewModel.setIsComposingMsg(isComposing: false)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
displayName: self.viewModel.displayName.value,
username: self.viewModel.userName.value)
self.updateNavigationBarShadow()
}
private func importDocument() {
currentDocumentPickerMode = .picking
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.item])
documentPicker.delegate = self
documentPicker.modalPresentationStyle = .formSheet
self.present(documentPicker, animated: true, completion: nil)
}
private func showNoPermissionsAlert(title: String) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default) { (_: UIAlertAction!) -> Void in }
alert.addAction(okAction)
self.present(alert, animated: true, completion: nil)
}
// MARK: photo library
private func presentBackgroundRecordingAlert() {
let alert = UIAlertController(title: nil, message: L10n.DataTransfer.recordInBackgroundWarning, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: L10n.Global.ok, style: .default, handler: { [weak self] _ in
UserDefaults.standard.setValue(true, forKey: fileRecordingLimitationInBackgroundKey)
self?.recordVideoFile()
}))
self.present(alert, animated: true, completion: nil)
}
private func canRecordVideoFile() -> Bool {
/*According to Apple, warning about camera performance in the background
should be presented for iPad devices running on versions lower than iOS 16
*/
if #available(iOS 16.0, *) {
return true
}
return UIDevice.current.userInterfaceIdiom != .pad || UserDefaults.standard.bool(forKey: fileRecordingLimitationInBackgroundKey)
}
private func recordVideoFile() {
if canRecordVideoFile() {
self.viewModel.recordVideoFile()
} else {
presentBackgroundRecordingAlert()
}
}
func selectItemsFromPhotoLibrary() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
var config = PHPickerConfiguration()
config.selectionLimit = 0
let pickerViewController = PHPickerViewController(configuration: config)
pickerViewController.delegate = self
self.present(pickerViewController, animated: true, completion: nil)
}
}
func recordVideo() {
if AVCaptureDevice.authorizationStatus(for: AVMediaType.audio) == AVAuthorizationStatus.authorized {
if AVCaptureDevice.authorizationStatus(for: AVMediaType.video) == AVAuthorizationStatus.authorized {
self.recordVideoFile()
} else {
AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler: { [weak self] (granted: Bool) -> Void in
guard let self = self else { return }
if granted == true {
self.recordVideoFile()
} else {
self.showNoPermissionsAlert(title: L10n.Alerts.noMediaPermissionsTitle)
}
})
}
} else {
AVCaptureDevice.requestAccess(for: AVMediaType.audio, completionHandler: {[weak self] (granted: Bool) -> Void in
guard let self = self else { return }
if granted == true {
if AVCaptureDevice.authorizationStatus(for: AVMediaType.video) == AVAuthorizationStatus.authorized {
self.recordVideoFile()
} else {
AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler: { (granted: Bool) -> Void in
if granted == true {
self.recordVideoFile()
} else {
self.showNoPermissionsAlert(title: L10n.Alerts.noMediaPermissionsTitle)
}
})
}
} else {
self.showNoPermissionsAlert(title: L10n.Alerts.noMediaPermissionsTitle)
}
})
}
}
func recordAudio() {
if AVCaptureDevice.authorizationStatus(for: AVMediaType.audio) == AVAuthorizationStatus.authorized {
self.viewModel.recordAudioFile()
} else {
AVCaptureDevice.requestAccess(for: AVMediaType.audio, completionHandler: { [weak self] (granted: Bool) -> Void in
guard let self = self else { return }
if granted == true {
self.viewModel.recordAudioFile()
} else {
self.showNoPermissionsAlert(title: L10n.Alerts.noMediaPermissionsTitle)
}
})
}
}
func takePicture() {
if UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera) {
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
imagePicker.sourceType = UIImagePickerController.SourceType.camera
imagePicker.cameraDevice = UIImagePickerController.CameraDevice.rear
imagePicker.modalPresentationStyle = .overFullScreen
self.present(imagePicker, animated: false, completion: nil)
}
}
func fixImageOrientation(image: UIImage) -> UIImage {
UIGraphicsBeginImageContext(image.size)
image.draw(at: .zero)
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage ?? image
}
func importImage() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
imagePicker.sourceType = UIImagePickerController.SourceType.photoLibrary
imagePicker.mediaTypes = [kUTTypeImage as String, kUTTypeMovie as String]
imagePicker.modalPresentationStyle = .overFullScreen
self.present(imagePicker, animated: true, completion: nil)
}
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
results.forEach { (result) in
let imageFileName: String = result.itemProvider.suggestedName ?? "file"
let provider = result.itemProvider
switch self.getAssetTypeFrom(itemProvider: provider) {
case .gif:
provider.loadDataRepresentation(forTypeIdentifier: UTType.gif.identifier) { [weak self] (data, _) in
guard let self = self,
let data = data else { return }
self.viewModel.sendAndSaveFile(displayName: imageFileName + ".gif", imageData: data)
}
case .image:
provider.loadObject(ofClass: UIImage.self) { [weak self] (object, _) in
guard let self = self,
let image = object as? UIImage,
let imageData = image.jpegData(compressionQuality: 0.5) else { return }
self.viewModel.sendAndSaveFile(displayName: imageFileName + ".jpeg", imageData: imageData)
}
case .video:
provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] (data, _) in
guard let self = self,
let data = data else { return }
self.viewModel.sendAndSaveFile(displayName: imageFileName + ".mov", imageData: data)
}
default:
break
}
}
}
private func getAssetTypeFrom(itemProvider: NSItemProvider) -> FileTransferType {
if itemProvider.hasItemConformingToTypeIdentifier(UTType.gif.identifier) {
return .gif
} else if itemProvider.canLoadObject(ofClass: UIImage.self) {
return .image
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
return .video
} else {
return .unknown
}
}
// swiftlint:disable cyclomatic_complexity
internal func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true, completion: nil)
var image: UIImage!
if picker.sourceType == UIImagePickerController.SourceType.camera {
// image from camera
if let img = info[.editedImage] as? UIImage {
image = img
} else if let img = info[.originalImage] as? UIImage {
image = self.fixImageOrientation(image: img)
}
// copy image to tmp
let imageFileName = "IMG.jpeg"
guard let imageData = image.jpegData(compressionQuality: 0.5) else { return }
self.viewModel.sendAndSaveFile(displayName: imageFileName, imageData: imageData)
return
}
guard picker.sourceType == UIImagePickerController.SourceType.photoLibrary,
let phAsset = info[UIImagePickerController.InfoKey.phAsset] as? PHAsset else { return }
let imageFileName = phAsset.value(forKey: "filename") as? String ?? "Unknown"
// image from library
if phAsset.mediaType == .image {
if let img = info[.editedImage] as? UIImage {
image = img
} else if let img = info[.originalImage] as? UIImage {
image = img
}
guard let imageData = image.jpegData(compressionQuality: 0.5) else { return }
self.viewModel.sendAndSaveFile(displayName: imageFileName, imageData: imageData)
// self.viewModel.sendImageFromPhotoLibraty(image: image, imageName: imageFileName, localIdentifier: phAsset.localIdentifier)
return
}
guard phAsset.mediaType == .video else { return }
PHImageManager
.default()
.requestAVAsset(forVideo: phAsset,
options: PHVideoRequestOptions(),
resultHandler: { (asset, _, _) -> Void in
guard let asset = asset as? AVURLAsset,
let videoData = NSData(contentsOf: asset.url) else {
return
}
self.viewModel.sendAndSaveFile(displayName: imageFileName, imageData: videoData as Data)
})
}
func setupNavTitle(profileImageData: Data?, displayName: String? = nil, username: String?) {
let isPortrait = UIScreen.main.bounds.size.width < UIScreen.main.bounds.size.height
let imageSize = isPortrait ? CGFloat(36.0) : CGFloat(32.0)
let imageOffsetY = CGFloat(5.0)
let infoPadding = CGFloat(8.0)
let maxNameLength = CGFloat(128.0)
var userNameYOffset = CGFloat(9.0)
var nameSize = CGFloat(18.0)
let navbarFrame = self.navigationController?.navigationBar.frame
let totalHeight = (44 + (navbarFrame?.origin.y ?? 0)) / 2
// Replace "< Home" with a back arrow while we are crunching everything to the left side of the bar for now.
self.navigationController?.navigationBar.backIndicatorImage = UIImage(named: "back_button")
self.navigationController?.navigationBar.backIndicatorTransitionMaskImage = UIImage(named: "back_button")
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: UIBarButtonItem.Style.plain, target: nil, action: nil)
self.navigationItem.setHidesBackButton(false, animated: false)
let titleView: UIView = UIView.init(frame: CGRect(x: 0, y: 0, width: view.frame.width - 32, height: totalHeight))
let profileImageView = UIImageView(frame: CGRect(x: 0, y: imageOffsetY, width: imageSize, height: imageSize))
profileImageView.frame = CGRect.init(x: 0, y: 0, width: imageSize, height: imageSize)
profileImageView.center = CGPoint.init(x: imageSize / 2, y: titleView.center.y)
if let profileName = displayName, !profileName.isEmpty {
profileImageView.addSubview(AvatarView(profileImageData: profileImageData, username: profileName, size: 30))
titleView.addSubview(profileImageView)
} else if let bestId = username {
profileImageView.addSubview(AvatarView(profileImageData: profileImageData, username: bestId, size: 30))
titleView.addSubview(profileImageView)
}
var dnlabelYOffset: CGFloat = 0
if !isPortrait {
userNameYOffset = 0
} else if UIDevice.current.hasNotch {
if displayName == nil || displayName == "" {
userNameYOffset = 7
} else if username == nil || username == "" {
dnlabelYOffset = 7
} else {
dnlabelYOffset = 2
userNameYOffset = 18
}
} else {
if displayName == nil || displayName == "" {
userNameYOffset = 1
} else if username == nil || username == "" {
dnlabelYOffset = 1
} else {
dnlabelYOffset = -4
userNameYOffset = 10
}
}
if let name = displayName, !name.isEmpty {
let dnlabel: UILabel = UILabel.init(frame: CGRect.init(x: imageSize + infoPadding, y: dnlabelYOffset, width: maxNameLength, height: 20))
dnlabel.text = name
dnlabel.font = UIFont.systemFont(ofSize: nameSize)
dnlabel.textColor = UIColor.jamiButtonDark
dnlabel.textAlignment = .left
titleView.addSubview(dnlabel)
nameSize = 14.0
}
if isPortrait || displayName == nil || displayName == "" {
let frame = CGRect.init(x: imageSize + infoPadding,
y: userNameYOffset,
width: maxNameLength,
height: 24)
let unlabel: UILabel = UILabel.init(frame: frame)
unlabel.text = username
unlabel.font = UIFont.systemFont(ofSize: nameSize)
unlabel.textColor = UIColor.jamiButtonDark
unlabel.textAlignment = .left
titleView.addSubview(unlabel)
}
let tapGesture = UITapGestureRecognizer()
titleView.addGestureRecognizer(tapGesture)
tapGesture.rx.event
.throttle(Durations.switchThrottlingDuration.toTimeInterval(), scheduler: MainScheduler.instance)
.bind(onNext: { [weak self] _ in
self?.contactTapped()
})
.disposed(by: disposeBag)
titleView.backgroundColor = UIColor.clear
self.navigationItem.titleView = titleView
}
func contactTapped() {
self.viewModel.showContactInfo()
}
private func setRightNavigationButtons() {
// do not show call buttons for swarm with multiple participants
if self.viewModel.conversation.value.getParticipants().count > 1 {
return
}
let audioCallItem = UIBarButtonItem()
audioCallItem.image = UIImage(asset: Asset.callButton)
audioCallItem.rx.tap.throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance)
.subscribe(onNext: { [weak self] in
self?.placeAudioOnlyCall()
})
.disposed(by: self.disposeBag)
let videoCallItem = UIBarButtonItem()
videoCallItem.image = UIImage(asset: Asset.videoRunning)
videoCallItem.rx.tap.throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance)
.subscribe(onNext: { [weak self] in
self?.placeCall()
})
.disposed(by: self.disposeBag)
// Items are from right to left
if self.viewModel.isAccountSip {
self.navigationItem.rightBarButtonItem = audioCallItem
} else {
self.navigationItem.rightBarButtonItems = [videoCallItem, audioCallItem]
}
}
func setupUI() {
spinnerView.backgroundColor = UIColor.jamiMsgBackground
self.view.backgroundColor = UIColor.jamiMsgTextFieldBackground
Observable<(Data?, String?, String)>.combineLatest(self.viewModel.profileImageData.asObservable(),
self.viewModel.displayName.asObservable(),
self.viewModel.userName.asObservable()) { profileImage, displayName, username in
return (profileImage, displayName, username)
}
.observe(on: MainScheduler.instance)
.subscribe({ [weak self] profileData -> Void in
self?.setupNavTitle(profileImageData: profileData.element?.0,
displayName: profileData.element?.1,
username: profileData.element?.2)
return
})
.disposed(by: self.disposeBag)
self.setRightNavigationButtons()
self.viewModel.showCallButton
.observe(on: MainScheduler.instance)
.startWith(self.viewModel.haveCurrentCall())
.subscribe(onNext: { [weak self] show in
if show {
DispatchQueue.main.async {
if self?.viewModel.currentCallId.value.isEmpty ?? true {
return
}
self?.currentCallButton.isHidden = false
self?.currentCallLabel.isHidden = false
self?.callButtonHeightConstraint.constant = 60
}
return
}
self?.currentCallButton.isHidden = true
self?.currentCallLabel.isHidden = true
self?.callButtonHeightConstraint.constant = 0
})
.disposed(by: disposeBag)
currentCallButton.rx.tap
.throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance)
.subscribe(onNext: { [weak self] in
self?.viewModel.openCall()
})
.disposed(by: self.disposeBag)
viewModel.bestName
.share()
.asObservable()
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] bestName in
let name = bestName.replacingOccurrences(of: "\0", with: "")
guard !name.isEmpty else { return }
let nameNSString = name as NSString
self?.conversationInSyncLabel.text = L10n.Conversation.synchronizationMessage(nameNSString)
})
.disposed(by: self.disposeBag)
self.conversationInSyncLabel.backgroundColor = UIColor(hexString: self.viewModel.conversation.value.preferences.color)
}
func placeCall() {
self.viewModel.startCall()
}
func placeAudioOnlyCall() {
self.viewModel.startAudioCall()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.swiftUIViewAdded {
self.addSwiftUIView()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.navigationController?.navigationBar.shadowImage = UIImage()
self.navigationController?.navigationBar.layer.shadowOpacity = 0
self.viewModel.setIsComposingMsg(isComposing: false)
self.viewModel.setMessagesAsRead()
}
private func messagesLoadingFinished() {
self.spinnerView.isHidden = true
}
func setupBindings() {
self.viewModel.shouldDismiss
.observe(on: MainScheduler.instance)
.subscribe { [weak self] dismiss in
guard let self = self, dismiss else { return }
_ = self.navigationController?.popViewController(animated: true)
} onError: { _ in
}
.disposed(by: self.disposeBag)
self.viewModel.showInvitation
.observe(on: MainScheduler.instance)
.subscribe { [weak self] show in
guard let self = self else { return }
if show {
if self.view.window?.rootViewController is InvitationViewController {
return
}
self.navigationItem.rightBarButtonItems = []
self.viewModel.openInvitationView(parentView: self)
} else {
self.setRightNavigationButtons()
}
} onError: { _ in
}
.disposed(by: self.disposeBag)
self.viewModel.synchronizing
.startWith(self.viewModel.synchronizing.value)
.observe(on: MainScheduler.instance)
.subscribe { [weak self] synchronizing in
guard let self = self else { return }
self.conversationInSyncLabel.isHidden = !synchronizing
} onError: { _ in
}
.disposed(by: self.disposeBag)
}
func updateNavigationBarShadow() {
self.navigationController?.navigationBar.shadowImage = nil
self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
self.navigationController?.navigationBar.layer.shadowOffset = CGSize(width: 0.0, height: 0.5)
self.navigationController?.navigationBar.layer.shadowOpacity = 0.1
}
// MARK: ContactPickerDelegate
func presentContactPicker(contactPickerVC: ContactPickerViewController) {
self.addChild(contactPickerVC)
var statusBarHeight: CGFloat = 0
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let statusBarManager = windowScene.statusBarManager {
statusBarHeight = statusBarManager.statusBarFrame.height
}
let screenSize = UIScreen.main.bounds
let screenWidth = screenSize.width
let screenHeight = screenSize.height
let newFrame = CGRect(x: 0, y: -statusBarHeight, width: screenWidth, height: screenHeight + statusBarHeight)
let initialFrame = CGRect(x: 0, y: screenHeight, width: screenWidth, height: screenHeight + statusBarHeight)
contactPickerVC.view.frame = initialFrame
self.view.addSubview(contactPickerVC.view)
contactPickerVC.didMove(toParent: self)
UIView.animate(withDuration: 0.2, animations: { [weak contactPickerVC] in
guard let contactPickerVC = contactPickerVC else { return }
contactPickerVC.view.frame = newFrame
}, completion: { _ in
})
}
}
// MARK: Location sharing
extension ConversationViewController {
func startLocationSharing() {
if self.checkLocationAuthorization() && self.isNotAlreadySharingWithThisContact() {
if self.isLocationSharingDurationLimited {
self.viewModel.startSendingLocation(duration: TimeInterval(self.locationSharingDuration * 60))
} else {
self.viewModel.startSendingLocation()
}
}
}
private func isNotAlreadySharingWithThisContact() -> Bool {
if self.viewModel.isAlreadySharingMyLocation() {
let alert = UIAlertController.init(title: L10n.Alerts.alreadylocationSharing,
message: nil,
preferredStyle: .alert)
alert.addAction(.init(title: L10n.Global.ok, style: UIAlertAction.Style.cancel))
self.present(alert, animated: true, completion: nil)
return false
}
return true
}
private func showGoToSettingsAlert(title: String) {
let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: L10n.Actions.goToSettings, style: .default, handler: { (_) in
if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, completionHandler: nil)
}
}))
alertController.addAction(UIAlertAction(title: L10n.Global.cancel, style: .cancel, handler: nil))
self.present(alertController, animated: true, completion: nil)
}
private func checkLocationAuthorization() -> Bool {
switch CLLocationManager().authorizationStatus {
case .notDetermined: locationManager.requestWhenInUseAuthorization()
case .restricted, .denied: self.showGoToSettingsAlert(title: L10n.Alerts.noLocationPermissionsTitle)
case .authorizedAlways, .authorizedWhenInUse: return true
@unknown default: break
}
return false
}
}
extension ConversationViewController: ContactPickerViewControllerDelegate {
func contactPickerDismissed() {
self.view.addGestureRecognizer(self.screenTapRecognizer)
self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
displayName: self.viewModel.displayName.value,
username: self.viewModel.userName.value)
self.updateNavigationBarShadow()
}
}
extension ConversationViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
if currentDocumentPickerMode == .picking {
if let url = urls.first, url.startAccessingSecurityScopedResource() {
let filePath = url.absoluteURL.path
self.log.debug("Successfully imported \(filePath)")
let fileName = url.absoluteURL.lastPathComponent
do {
let data = try Data(contentsOf: url)
self.viewModel.sendAndSaveFile(displayName: fileName, imageData: data)
} catch {
self.viewModel.sendFile(filePath: filePath, displayName: fileName)
}
url.stopAccessingSecurityScopedResource()
}
}
currentDocumentPickerMode = .none
}
}
extension ConversationViewController: UIDocumentInteractionControllerDelegate {
internal func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
if let navigationController = self.navigationController {
return navigationController
}
return self
}
}
// MARK: - Messages actions
extension ConversationViewController {
func saveFile(url: URL) {
if url.pathExtension.isImageExtension() {
saveGIFOrImage(url: url)
} else {
saveFileToDocuments(fileURL: url)
}
}
func presentPreview(message: MessageContentVM) {
guard let url = message.url else { return }
if message.player != nil {
presentPlayer(message: message)
} else {
openDocument(url: url)
}
}
func presentActivityControllerWithItems(items: [Any]) {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = self.view
activityViewController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection()
activityViewController.popoverPresentationController?.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxX, width: 0, height: 0)
self.present(activityViewController, animated: true, completion: nil)
}
func saveGIFOrImage(url: URL) {
PHPhotoLibrary.shared().performChanges({
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, fileURL: url, options: nil)
}, completionHandler: { _, error in
guard let error = error else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.showAlert(error: error)
}
})
}
@objc
func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
if let error = error {
self.showAlert(error: error)
}
}
func showAlert(error: Error) {
let allert = UIAlertController(title: L10n.Conversation.errorSavingImage, message: error.localizedDescription, preferredStyle: .alert)
allert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(allert, animated: true)
}
func saveFileToDocuments(fileURL: URL) {
currentDocumentPickerMode = .saving
let documentPicker = UIDocumentPickerViewController(forExporting: [fileURL])
documentPicker.delegate = self
documentPicker.modalPresentationStyle = .formSheet
self.present(documentPicker, animated: true, completion: nil)
}
func presentPlayer(message: MessageContentVM) {
self.viewModel.openFullScreenPreview(parentView: self, viewModel: message.player, image: nil, initialFrame: CGRect.zero, delegate: message)
}
func openDocument(url: URL) {
DispatchQueue.main.async {
let interactionController = UIDocumentInteractionController(url: url)
interactionController.delegate = self
interactionController.presentPreview(animated: true)
}
}
}
// swiftlint:enable type_body_length
// swiftlint:enable file_length