ConversationViewController.swift 38.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
/*
 *  Copyright (C) 2017 Savoir-faire Linux Inc.
 *
 *  Author: Silbino Gonçalves Matado <silbino.gmatado@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 RxSwift
23
import Reusable
24
import SwiftyBeaver
25 26
import Photos
import MobileCoreServices
27

28 29
// swiftlint:disable file_length
// swiftlint:disable type_body_length
30
class ConversationViewController: UIViewController,
31 32
                                  UIImagePickerControllerDelegate, UINavigationControllerDelegate,
                                  UIDocumentPickerDelegate, StoryboardBased, ViewModelBased {
33

34 35
    let log = SwiftyBeaver.self

36 37
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var spinnerView: UIView!
38 39 40

    let disposeBag = DisposeBag()

41
    var viewModel: ConversationViewModel!
Thibault Wittemberg's avatar
Thibault Wittemberg committed
42
    var messageViewModels: [MessageViewModel]?
43
    var textFieldShouldEndEditing = false
44
    var bottomOffset: CGFloat = 0
45
    let scrollOffsetThreshold: CGFloat = 600
46
    var bottomHeight: CGFloat = 0.00
47

48 49
    var keyboardDismissTapRecognizer: UITapGestureRecognizer!

50 51 52
    override func viewDidLoad() {
        super.viewDidLoad()

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
53
        self.configureRingNavigationBar()
54 55 56 57 58 59 60 61
        self.setupUI()
        self.setupTableView()
        self.setupBindings()

        /*
         Register to keyboard notifications to adjust tableView insets when the keybaord appears
         or disappears
         */
62
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(withNotification:)), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
63
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(withNotification:)), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
64

65
        keyboardDismissTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
66 67
    }

68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
    func importDocument() {
        let documentPicker = UIDocumentPickerViewController(documentTypes: ["public.item"], in: .import)
        documentPicker.delegate = self
        documentPicker.modalPresentationStyle = .formSheet
        self.present(documentPicker, animated: true, completion: nil)
    }

    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        let filePath = urls[0].absoluteURL.path
        self.log.debug("Successfully imported \(filePath)")
        let fileName = urls[0].absoluteURL.lastPathComponent
        self.viewModel.sendFile(filePath: filePath, displayName: fileName)
    }

    @objc func imageTapped() {

        let alert = UIAlertController.init(title: nil,
                                           message: nil,
                                           preferredStyle: .alert)

        let pictureAction = UIAlertAction(title: "Upload photo or movie", style: UIAlertActionStyle.default) { _ in
            self.importImage()
        }

        let documentsAction = UIAlertAction(title: "Upload file", style: UIAlertActionStyle.default) { _ in
            self.importDocument()
        }

        let cancelAction = UIAlertAction(title: L10n.Alerts.profileCancelPhoto, style: UIAlertActionStyle.cancel)
        alert.addAction(pictureAction)
        alert.addAction(documentsAction)
        alert.addAction(cancelAction)
        alert.popoverPresentationController?.sourceView = self.view
        alert.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection()
        alert.popoverPresentationController?.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxX, width: 0, height: 0)
        self.present(alert, animated: true, completion: nil)
    }

    func takePicture() {
        if UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.camera) {
            let imagePicker = UIImagePickerController()
            imagePicker.delegate = self
            imagePicker.sourceType = UIImagePickerControllerSourceType.camera
111
            imagePicker.cameraDevice = UIImagePickerControllerCameraDevice.rear
112 113 114 115 116
            imagePicker.modalPresentationStyle = .overFullScreen
            self.present(imagePicker, animated: false, completion: nil)
        }
    }

117 118 119 120 121 122 123 124
    func fixImageOrientation(image: UIImage)->UIImage {
        UIGraphicsBeginImageContext(image.size)
        image.draw(at: .zero)
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return newImage ?? image
    }

125 126 127 128 129 130 131 132 133 134 135 136 137
    func importImage() {
        let imagePicker = UIImagePickerController()
        imagePicker.delegate = self
        imagePicker.allowsEditing = true
        imagePicker.sourceType = UIImagePickerControllerSourceType.photoLibrary
        imagePicker.mediaTypes = [kUTTypeImage as String, kUTTypeMovie as String]
        imagePicker.modalPresentationStyle = .overFullScreen
        self.present(imagePicker, animated: true, completion: nil)
    }

    func copyImageToCache(image: UIImage, imagePath: String) {
        guard let imageData =  UIImagePNGRepresentation(image) else { return }
        do {
138 139
            self.log.debug("copying image to: \(String(describing: imagePath))")
            try imageData.write(to: URL(fileURLWithPath: imagePath), options: .atomic)
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
        } catch {
            self.log.error("couldn't copy image to cache")
        }
    }

    // swiftlint:disable cyclomatic_complexity
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String: Any]) {

        picker.dismiss(animated: true, completion: nil)

        var image: UIImage!

        if picker.sourceType == UIImagePickerControllerSourceType.camera {
            // image from camera
            if let img = info[UIImagePickerControllerEditedImage] as? UIImage {
                image = img
            } else if let img = info[UIImagePickerControllerOriginalImage] as? UIImage {
157
                image = self.fixImageOrientation(image: img)
158 159 160
            }
            // copy image to tmp
            let imageFileName = "IMG.png"
161 162
            guard let imageData =  UIImagePNGRepresentation(image) else { return }
            self.viewModel.sendAndSaveFile(displayName: imageFileName, imageData: imageData)
163 164 165 166 167 168 169 170
        } else if picker.sourceType == UIImagePickerControllerSourceType.photoLibrary {
            // image from library
            guard let imageURL = info[UIImagePickerControllerReferenceURL] as? URL else { return }
            self.log.debug("imageURL: \(String(describing: imageURL))")

            let result = PHAsset.fetchAssets(withALAssetURLs: [imageURL], options: nil)
            var imageFileName = result.firstObject?.value(forKey: "filename") as? String ?? "Unknown"

171 172
            // seems that HEIC, HEIF, and JPG files in the iOS photo library start with 0x89 0x50 (png)
            // so funky cold medina
173
            let pathExtension = (imageFileName as NSString).pathExtension
174 175 176
            if pathExtension.caseInsensitiveCompare("heic") == .orderedSame ||
               pathExtension.caseInsensitiveCompare("heif") == .orderedSame ||
               pathExtension.caseInsensitiveCompare("jpg") == .orderedSame {
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192
                imageFileName = (imageFileName as NSString).deletingPathExtension + ".png"
            }

            let localCachePath = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(imageFileName)
            self.log.debug("localCachePath: \(String(describing: localCachePath))")

            guard let phAsset = result.firstObject else { return }

            if phAsset.mediaType == .image {
                if let img = info[UIImagePickerControllerEditedImage] as? UIImage {
                    image = img
                } else if let img = info[UIImagePickerControllerOriginalImage] as? UIImage {
                    image = img
                }
                // copy image to tmp
                copyImageToCache(image: image, imagePath: localCachePath!.path)
193 194 195
                self.viewModel.sendFile(filePath: localCachePath!.path,
                                        displayName: imageFileName,
                                        localIdentifier: result.firstObject?.localIdentifier)
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
            } else if phAsset.mediaType == .video {
                PHImageManager.default().requestAVAsset(forVideo: phAsset,
                                                        options: PHVideoRequestOptions(),
                                                        resultHandler: { (asset, _, _) -> Void in
                    guard let asset = asset as? AVURLAsset else {
                        self.log.error("couldn't get asset")
                        return
                    }
                    guard let videoData = NSData(contentsOf: asset.url) else {
                        self.log.error("couldn't get movie data")
                        return
                    }
                    self.log.debug("copying movie to: \(String(describing: localCachePath))")
                    videoData.write(toFile: (localCachePath?.path)!, atomically: true)
                    self.viewModel.sendFile(filePath: localCachePath!.path, displayName: imageFileName)
                })
            }
        }
    }
    // swiftlint:enable cyclomatic_complexity

217 218 219 220 221
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        UIApplication.shared.statusBarStyle = .default
    }

222 223
    @objc func dismissKeyboard() {
        self.becomeFirstResponder()
224
        view.removeGestureRecognizer(keyboardDismissTapRecognizer)
225
    }
226

227
    @objc func keyboardWillShow(withNotification notification: Notification) {
228
        let userInfo: Dictionary = notification.userInfo!
229 230
        guard let keyboardFrame: NSValue = userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue else { return }

231 232 233
        let keyboardRectangle = keyboardFrame.cgRectValue
        let keyboardHeight = keyboardRectangle.height

234 235
        var heightOffset = CGFloat(0.0)
        if keyboardHeight != self.messageAccessoryView.frame.height {
236
            if UIDevice.current.hasNotch {
237
                heightOffset = -55
238
            } else {
239
                heightOffset = -20
240
            }
241
            self.view.addGestureRecognizer(keyboardDismissTapRecognizer)
242 243 244 245
        }

        self.tableView.contentInset.bottom = keyboardHeight + heightOffset
        self.tableView.scrollIndicatorInsets.bottom = keyboardHeight + heightOffset
246
        self.bottomHeight = keyboardHeight + heightOffset
247

248
        self.scrollToBottom(animated: false)
249 250 251
        self.updateBottomOffset()
    }

252
    @objc func keyboardWillHide(withNotification notification: Notification) {
253 254
        self.tableView.contentInset.bottom = self.messageAccessoryView.frame.height
        self.tableView.scrollIndicatorInsets.bottom = self.messageAccessoryView.frame.height
255 256 257
        self.updateBottomOffset()
    }

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
258
    func setupNavTitle(profileImageData: Data?, displayName: String? = nil, username: String?) {
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277
        let imageSize       = CGFloat(36.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     = ((navbarFrame?.size.height ?? 0) + (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: UIBarButtonItemStyle.plain, target: nil, action: nil)

        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)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
278

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
279
        if let profileName = displayName, !profileName.isEmpty {
280
            profileImageView.addSubview(AvatarView(profileImageData: profileImageData, username: profileName, size: 30))
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
281 282
            titleView.addSubview(profileImageView)
        } else if let bestId = username {
283
            profileImageView.addSubview(AvatarView(profileImageData: profileImageData, username: bestId, size: 30))
Andreas Traczyk's avatar
Andreas Traczyk committed
284
            titleView.addSubview(profileImageView)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
285 286
        }

287
        var dnlabelYOffset: CGFloat = 0
288
        if UIDevice.current.hasNotch {
289
            if displayName == nil || displayName == "" {
290
                userNameYOffset = 7
291
            } else {
292 293
                dnlabelYOffset = 2
                userNameYOffset = 18
294
            }
295
        } else {
296 297
            if displayName == nil || displayName == ""  {
                userNameYOffset = 1
298
            } else {
299 300
            dnlabelYOffset = -4
            userNameYOffset = 10
301
            }
302 303
        }

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
304
        if let name = displayName, !name.isEmpty {
305
            let dnlabel: UILabel = UILabel.init(frame: CGRect.init(x: imageSize + infoPadding, y: dnlabelYOffset, width: maxNameLength, height: 20))
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
306
            dnlabel.text = name
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
307
            dnlabel.font = UIFont.systemFont(ofSize: nameSize)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
308
            dnlabel.textColor = UIColor.ringMain
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
309 310 311 312 313 314 315 316
            dnlabel.textAlignment = .left
            titleView.addSubview(dnlabel)
            nameSize = 14.0
        }

        let unlabel: UILabel = UILabel.init(frame: CGRect.init(x: imageSize + infoPadding, y: userNameYOffset, width: maxNameLength, height: 24))
        unlabel.text = username
        unlabel.font = UIFont.systemFont(ofSize: nameSize)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
317
        unlabel.textColor = UIColor.ringMain
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
318 319
        unlabel.textAlignment = .left
        titleView.addSubview(unlabel)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
320 321
        let tapGesture = UITapGestureRecognizer()
        titleView.addGestureRecognizer(tapGesture)
322 323 324
        tapGesture.rx.event
        .throttle(RxTimeInterval(2), scheduler: MainScheduler.instance)
        .bind(onNext: { [weak self] _ in
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
325 326
            self?.contactTapped()
        }).disposed(by: disposeBag)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
327 328

        self.navigationItem.titleView = titleView
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
329
    }
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
330

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
331 332
    func contactTapped() {
        self.viewModel.showContactInfo()
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
333 334
    }

335
    func setupUI() {
336

337 338
        self.messageAccessoryView.shareButton.tintColor = UIColor.ringMain
        self.messageAccessoryView.cameraButton.tintColor = UIColor.ringMain
339 340
        self.messageAccessoryView.sendButton.contentVerticalAlignment = .fill
        self.messageAccessoryView.sendButton.contentHorizontalAlignment = .fill
341 342 343 344
        self.tableView.backgroundColor = UIColor.ringMsgBackground
        self.messageAccessoryView.backgroundColor = UIColor.ringMsgTextFieldBackground
        self.view.backgroundColor = UIColor.ringMsgTextFieldBackground

345 346 347 348 349 350 351 352 353 354
        self.messageAccessoryView.shareButton.rx.tap
            .subscribe(onNext: { [unowned self] in
                self.imageTapped()
            }).disposed(by: self.disposeBag)

        self.messageAccessoryView.cameraButton.rx.tap
            .subscribe(onNext: { [unowned self] in
                self.takePicture()
            }).disposed(by: self.disposeBag)

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
355 356 357 358 359 360 361
        self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
                           displayName: self.viewModel.displayName.value,
                           username: self.viewModel.userName.value)

        Observable<(Data?, String?, String)>.combineLatest(self.viewModel.profileImageData.asObservable(),
                                                           self.viewModel.displayName.asObservable(),
                                                           self.viewModel.userName.asObservable()) { profileImage, displayName, username in
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
362
                                                            return (profileImage, displayName, username)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
363 364 365 366 367 368 369 370 371
            }
            .observeOn(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)
372 373 374

        self.tableView.contentInset.bottom = messageAccessoryView.frame.size.height
        self.tableView.scrollIndicatorInsets.bottom = messageAccessoryView.frame.size.height
375

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
376
        //set navigation buttons - call and send contact request
377 378
        let inviteItem = UIBarButtonItem()
        inviteItem.image = UIImage(named: "add_person")
379 380
        inviteItem.rx.tap.throttle(0.5, scheduler: MainScheduler.instance)
            .subscribe(onNext: { [unowned self] in
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400
                self.inviteItemTapped()
            })
            .disposed(by: self.disposeBag)

        self.viewModel.inviteButtonIsAvailable.asObservable()
            .bind(to: inviteItem.rx.isEnabled)
            .disposed(by: disposeBag)

        // call button
        let audioCallItem = UIBarButtonItem()
        audioCallItem.image = UIImage(asset: Asset.callButton)
        audioCallItem.rx.tap.throttle(0.5, scheduler: MainScheduler.instance)
            .subscribe(onNext: { [unowned self] in
                self.placeAudioOnlyCall()
            })
            .disposed(by: self.disposeBag)

        let videoCallItem = UIBarButtonItem()
        videoCallItem.image = UIImage(asset: Asset.videoRunning)
        videoCallItem.rx.tap.throttle(0.5, scheduler: MainScheduler.instance)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
401 402 403
            .subscribe(onNext: { [unowned self] in
                self.placeCall()
            }).disposed(by: self.disposeBag)
404

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
405
        // Items are from right to left
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
406 407 408 409 410 411 412 413 414 415 416
        self.navigationItem.rightBarButtonItems = [videoCallItem, audioCallItem, inviteItem]

        self.viewModel.inviteButtonIsAvailable
            .asObservable().map({ inviteButton in
                var buttons = [UIBarButtonItem]()
                buttons.append(videoCallItem)
                buttons.append(audioCallItem)
                if inviteButton {
                    buttons.append(inviteItem)
                }
                return buttons
417 418 419 420 421
            })
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { [weak self] buttons in
                self?.navigationItem.rightBarButtonItems = buttons
            }).disposed(by: self.disposeBag)
422 423 424 425
    }

    func inviteItemTapped() {
       self.viewModel?.sendContactRequest()
426 427
    }

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
428 429 430 431
    func placeCall() {
        self.viewModel.startCall()
    }

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
432 433 434 435
    func placeAudioOnlyCall() {
        self.viewModel.startAudioCall()
    }

436 437 438 439
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        self.scrollToBottom(animated: false)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
440
        self.textFieldShouldEndEditing = false
441 442 443 444 445 446 447
        self.messagesLoadingFinished()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        self.textFieldShouldEndEditing = true
448
        self.viewModel.setMessagesAsRead()
449 450 451
    }

    func setupTableView() {
Thibault Wittemberg's avatar
Thibault Wittemberg committed
452 453
        self.tableView.dataSource = self

454 455 456 457 458
        self.tableView.estimatedRowHeight = 50
        self.tableView.rowHeight = UITableViewAutomaticDimension
        self.tableView.separatorStyle = .none

        //Register cell
Thibault Wittemberg's avatar
Thibault Wittemberg committed
459 460
        self.tableView.register(cellType: MessageCellSent.self)
        self.tableView.register(cellType: MessageCellReceived.self)
461 462
        self.tableView.register(cellType: MessageCellDataTransferSent.self)
        self.tableView.register(cellType: MessageCellDataTransferReceived.self)
463
        self.tableView.register(cellType: MessageCellGenerated.self)
464 465

        //Bind the TableView to the ViewModel
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
466
        self.viewModel.messages.asObservable().subscribe(onNext: { [weak self] (messageViewModels) in
Thibault Wittemberg's avatar
Thibault Wittemberg committed
467
            self?.messageViewModels = messageViewModels
468
            self?.computeSequencing()
Thibault Wittemberg's avatar
Thibault Wittemberg committed
469 470
            self?.tableView.reloadData()
        }).disposed(by: self.disposeBag)
471 472

        //Scroll to bottom when reloaded
473
        self.tableView.rx.methodInvoked(#selector(UITableView.reloadData)).subscribe(onNext: { [unowned self] _ in
474 475
            self.scrollToBottomIfNeed()
            self.updateBottomOffset()
476
        }).disposed(by: disposeBag)
477 478 479 480 481 482 483 484 485 486 487 488 489 490 491
    }

    fileprivate func updateBottomOffset() {
        self.bottomOffset = self.tableView.contentSize.height
            - ( self.tableView.frame.size.height
                - self.tableView.contentInset.top
                - self.tableView.contentInset.bottom )
    }

    fileprivate func messagesLoadingFinished() {
        self.spinnerView.isHidden = true
    }

    fileprivate func scrollToBottomIfNeed() {
        if self.isBottomContentOffset {
492
            self.scrollToBottom(animated: false)
493 494 495 496
        }
    }

    fileprivate func scrollToBottom(animated: Bool) {
497 498 499 500 501
        let numberOfRows = self.tableView.numberOfRows(inSection: 0)
        if  numberOfRows > 0 {
            let last = IndexPath(row: numberOfRows - 1, section: 0)
            self.tableView.scrollToRow(at: last, at: .bottom, animated: animated)
        }
502 503 504
    }

    fileprivate var isBottomContentOffset: Bool {
505 506 507
        updateBottomOffset()
        let offset = abs((self.tableView.contentOffset.y + self.tableView.contentInset.top) - bottomOffset)
        return offset <= scrollOffsetThreshold
508 509 510 511 512 513 514 515 516 517 518
    }

    override var inputAccessoryView: UIView {
        return self.messageAccessoryView
    }

    override var canBecomeFirstResponder: Bool {
        return true
    }

    lazy var messageAccessoryView: MessageAccessoryView = {
519
        return MessageAccessoryView.loadFromNib()
520 521 522
    }()

    func setupBindings() {
523 524
        self.messageAccessoryView.sendButton.rx.tap.subscribe(onNext: { [unowned self] _ in
            guard let payload = self.messageAccessoryView.messageTextView.text, !payload.isEmpty else {
525 526 527
                return
            }
            self.viewModel.sendMessage(withContent: payload)
528
            self.messageAccessoryView.messageTextView.text = ""
529
            self.messageAccessoryView.setEmojiButtonVisibility(hide: false)
530
        }).disposed(by: self.disposeBag)
531 532 533 534 535

        self.messageAccessoryView.emojisButton.rx.tap.subscribe(onNext: { [unowned self] _ in
            self.viewModel.sendMessage(withContent: "👍")
        }).disposed(by: self.disposeBag)

536
        self.messageAccessoryView.messageTextViewHeight.asObservable().subscribe(onNext: { [unowned self] height in
537 538 539 540
            self.tableView.contentInset.bottom = self.bottomHeight + height - 35
            self.tableView.scrollIndicatorInsets.bottom = self.bottomHeight + height - 35
            self.scrollToBottom(animated: true)
            self.updateBottomOffset()
541
        }).disposed(by: self.disposeBag)
542

543
        self.messageAccessoryView.messageTextViewContent.asObservable().subscribe(onNext: { [unowned self] _ in
544
            self.messageAccessoryView.editingChanges()
545
        }).disposed(by: self.disposeBag)
546 547 548 549 550 551 552
    }

    // Avoid the keyboard to be hidden when the Send button is touched
    func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
        return textFieldShouldEndEditing
    }

553
    // MARK: - message formatting
554 555 556 557 558
    func computeSequencing() {
        var lastShownTime: Date?
        for (index, messageViewModel) in self.messageViewModels!.enumerated() {
            // time labels
            let time = messageViewModel.receivedDate
559
            if index == 0 ||  messageViewModel.bubblePosition() == .generated || messageViewModel.isTransfer {
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580
                // always show first message's time
                messageViewModel.timeStringShown = getTimeLabelString(forTime: time)
                lastShownTime = time
            } else {
                // only show time for new messages if beyond an arbitrary time frame (1 minute)
                // from the previously shown time
                let hourComp = Calendar.current.compare(lastShownTime!, to: time, toGranularity: .hour)
                let minuteComp = Calendar.current.compare(lastShownTime!, to: time, toGranularity: .minute)
                if hourComp == .orderedSame && minuteComp == .orderedSame {
                    messageViewModel.timeStringShown = nil
                } else {
                    messageViewModel.timeStringShown = getTimeLabelString(forTime: time)
                    lastShownTime = time
                }
            }
            // sequencing
            messageViewModel.sequencing = getMessageSequencing(forIndex: index)
        }
    }

    func getMessageSequencing(forIndex index: Int) -> MessageSequencing {
581 582
        if let messageItem = self.messageViewModels?[index] {
            let msgOwner = messageItem.bubblePosition()
583 584 585
            if self.messageViewModels?.count == 1 || index == 0 {
                if self.messageViewModels?.count == index + 1 {
                    return MessageSequencing.singleMessage
586
                }
587
                let nextMessageItem = index + 1 <= (self.messageViewModels?.count)!
588
                    ? self.messageViewModels?[index + 1] : nil
589 590
                if nextMessageItem != nil {
                    return msgOwner != nextMessageItem?.bubblePosition()
591
                        ? MessageSequencing.singleMessage : MessageSequencing.firstOfSequence
592
                }
593
            } else if self.messageViewModels?.count == index + 1 {
594
                let lastMessageItem = index - 1 >= 0 && index - 1 < (self.messageViewModels?.count)!
595
                    ? self.messageViewModels?[index - 1] : nil
596 597
                if lastMessageItem != nil {
                    return msgOwner != lastMessageItem?.bubblePosition()
598
                        ? MessageSequencing.singleMessage : MessageSequencing.lastOfSequence
599 600
                }
            }
601
            let lastMessageItem = index - 1 >= 0 && index - 1 < (self.messageViewModels?.count)!
602
                ? self.messageViewModels?[index - 1] : nil
603
            let nextMessageItem = index + 1 <= (self.messageViewModels?.count)!
604 605
                ? self.messageViewModels?[index + 1] : nil
            var sequencing = MessageSequencing.singleMessage
606 607
            if (lastMessageItem != nil) && (nextMessageItem != nil) {
                if msgOwner != lastMessageItem?.bubblePosition() && msgOwner == nextMessageItem?.bubblePosition() {
608
                    sequencing = MessageSequencing.firstOfSequence
609
                } else if msgOwner != nextMessageItem?.bubblePosition() && msgOwner == lastMessageItem?.bubblePosition() {
610
                    sequencing = MessageSequencing.lastOfSequence
611
                } else if msgOwner == nextMessageItem?.bubblePosition() && msgOwner == lastMessageItem?.bubblePosition() {
612
                    sequencing = MessageSequencing.middleOfSequence
613 614
                }
            }
615
            return sequencing
616
        }
617
        return MessageSequencing.unknown
618 619
    }

620 621 622
    func getTimeLabelString(forTime time: Date) -> String {
        // get the current time
        let currentDateTime = Date()
623

624 625 626 627 628 629 630 631 632 633
        // prepare formatter
        let dateFormatter = DateFormatter()
        if Calendar.current.compare(currentDateTime, to: time, toGranularity: .year) == .orderedSame {
            if Calendar.current.compare(currentDateTime, to: time, toGranularity: .weekOfYear) == .orderedSame {
                if Calendar.current.compare(currentDateTime, to: time, toGranularity: .day) == .orderedSame {
                    // age: [0, received the previous day[
                    dateFormatter.dateFormat = "h:mma"
                } else {
                    // age: [received the previous day, received 7 days ago[
                    dateFormatter.dateFormat = "E h:mma"
634
                }
635 636 637
            } else {
                // age: [received 7 days ago, received the previous year[
                dateFormatter.dateFormat = "MMM d, h:mma"
638
            }
639 640 641
        } else {
            // age: [received the previous year, inf[
            dateFormatter.dateFormat = "MMM d, yyyy h:mma"
642 643
        }

644 645
        // generate the string containing the message time
        return dateFormatter.string(from: time).uppercased()
646 647
    }

648 649 650 651 652 653
    func changeTransferStatus(_ cell: MessageCell,
                              _ indexPath: IndexPath?,
                              _ status: DataTransferStatus,
                              _ item: MessageViewModel,
                              _ conversationViewModel: ConversationViewModel) {
        switch status {
654 655 656
        case .created:
            if item.bubblePosition() == .sent {
                cell.statusLabel.isHidden = false
657
                cell.statusLabel.text = L10n.DataTransfer.readableStatusCreated
658 659 660
                cell.statusLabel.textColor = UIColor.darkGray
                cell.progressBar.isHidden = true
                cell.cancelButton.isHidden = false
661
                cell.cancelButton.setTitle(L10n.DataTransfer.readableStatusCancel, for: .normal)
662 663
                cell.buttonsHeightConstraint?.constant = 24.0
            }
664 665 666
        case .error:
            // show status
            cell.statusLabel.isHidden = false
667
            cell.statusLabel.text = L10n.DataTransfer.readableStatusError
668
            cell.statusLabel.textColor = UIColor.ringFailure
669 670
            // hide everything and shrink cell
            cell.progressBar.isHidden = true
671
            cell.acceptButton?.isHidden = true
672
            cell.cancelButton.isHidden = true
673
            cell.buttonsHeightConstraint?.constant = 0.0
674 675
        case .awaiting:
            cell.progressBar.isHidden = true
676
            cell.cancelButton.isHidden = false
677
            cell.buttonsHeightConstraint?.constant = 24.0
678 679 680
            if item.bubblePosition() == .sent {
                // status
                cell.statusLabel.isHidden = false
681
                cell.statusLabel.text = L10n.DataTransfer.readableStatusAwaiting
682
                cell.statusLabel.textColor = UIColor.ringSuccess
683
                cell.cancelButton.setTitle(L10n.DataTransfer.readableStatusCancel, for: .normal)
684 685 686 687 688 689 690 691 692 693 694 695
            } else if item.bubblePosition() == .received {
                // accept automatically if less than 10MB and is an image
                if let transferId = item.daemonId,
                    let isImage = viewModel.isTransferImage(transferId: transferId),
                    let size = viewModel.getTransferSize(transferId: transferId), isImage && size <= 10485760 {
                    if viewModel.acceptTransfer(transferId: transferId, interactionID: item.messageId, messageContent: &item.message.content) != .success {
                        _ = self.viewModel.cancelTransfer(transferId: transferId)
                    }
                }
                // hide status
                cell.statusLabel.isHidden = true
                cell.acceptButton?.isHidden = false
696
                cell.cancelButton.setTitle(L10n.DataTransfer.readableStatusRefuse, for: .normal)
697
            }
698 699 700
        case .ongoing:
            // status
            cell.statusLabel.isHidden = false
701
            cell.statusLabel.text = L10n.DataTransfer.readableStatusOngoing
702
            cell.statusLabel.textColor = UIColor.darkGray
703
            // start update progress timer process bar here
704 705 706 707 708 709
            guard let transferId = item.daemonId else { return }
            let progress = viewModel.getTransferProgress(transferId: transferId) ?? 0.0
            cell.progressBar.progress = progress
            cell.progressBar.isHidden = false
            cell.startProgressMonitor(item, viewModel)
            // hide accept button only
710
            cell.acceptButton?.isHidden = true
711
            cell.cancelButton.isHidden = false
712
            cell.cancelButton.setTitle(L10n.DataTransfer.readableStatusCancel, for: .normal)
713
            cell.buttonsHeightConstraint?.constant = 24.0
714 715 716
        case .canceled:
            // status
            cell.statusLabel.isHidden = false
717
            cell.statusLabel.text = L10n.DataTransfer.readableStatusCanceled
718
            cell.statusLabel.textColor = UIColor.ringWarning
719 720
            // hide everything and shrink cell
            cell.progressBar.isHidden = true
721
            cell.acceptButton?.isHidden = true
722
            cell.cancelButton.isHidden = true
723
            cell.buttonsHeightConstraint?.constant = 0.0
724 725 726
        case .success:
            // status
            cell.statusLabel.isHidden = false
727
            cell.statusLabel.text = L10n.DataTransfer.readableStatusSuccess
728
            cell.statusLabel.textColor = UIColor.ringSuccess
729 730
            // hide everything and shrink cell
            cell.progressBar.isHidden = true
731
            cell.acceptButton?.isHidden = true
732
            cell.cancelButton.isHidden = true
733
            cell.buttonsHeightConstraint?.constant = 0.0
734 735 736
        default: break
        }
    }
737

Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
738 739 740 741 742 743 744 745 746 747 748
    func addShareAction(cell: MessageCell, item: MessageViewModel) {
        let doubleTap = UITapGestureRecognizer()
        doubleTap.numberOfTapsRequired = 2
        cell.isUserInteractionEnabled = true
        cell.addGestureRecognizer(doubleTap)
        doubleTap.rx.event.bind(onNext: { [weak self] _ in
            self?.showShareMenu(transfer: item)
        }).disposed(by: cell.disposeBag)
    }

    func showShareMenu(transfer: MessageViewModel) {
749
        guard let file = transfer.transferedFile(conversationID: self.viewModel.conversation.value.conversationId) else {return}
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
750 751 752
        let itemToShare = [file]
        let activityViewController = UIActivityViewController(activityItems: itemToShare, applicationActivities: nil)
        activityViewController.popoverPresentationController?.sourceView = self.view
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
753 754
        activityViewController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection()
        activityViewController.popoverPresentationController?.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxX, width: 0, height: 0)
Kateryna Kostiuk's avatar
Kateryna Kostiuk committed
755 756 757
        activityViewController.excludedActivityTypes = [UIActivityType.airDrop]
        self.present(activityViewController, animated: true, completion: nil)
    }
758
}
Thibault Wittemberg's avatar
Thibault Wittemberg committed
759 760 761 762 763 764 765

extension ConversationViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.messageViewModels?.count ?? 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
766
        if let item = self.messageViewModels?[indexPath.row] {
767 768 769 770 771
            var type = MessageCell.self
            if item.isTransfer {
                type = item.bubblePosition() == .received ? MessageCellDataTransferReceived.self : MessageCellDataTransferSent.self
            } else {
                type =  item.bubblePosition() == .received ? MessageCellReceived.self :
772 773 774
                    item.bubblePosition() == .sent ? MessageCellSent.self :
                    item.bubblePosition() == .generated ? MessageCellGenerated.self :
                    MessageCellGenerated.self
775
            }
776
            let cell = tableView.dequeueReusableCell(for: indexPath, cellType: type)
777
            cell.configureFromItem(viewModel, self.messageViewModels, cellForRowAt: indexPath)
778

779
            if item.isTransfer {
780
                cell.acceptButton?.setTitle(L10n.DataTransfer.readableStatusAccept, for: .normal)
781 782
                item.lastTransferStatus = .unknown
                changeTransferStatus(cell, nil, item.message.transferStatus, item, viewModel)
783 784 785 786
                item.transferStatus.asObservable()
                    .observeOn(MainScheduler.instance)
                    .filter {
                        return $0 != DataTransferStatus.unknown && $0 != item.lastTransferStatus && $0 != item.initialTransferStatus }
787 788
                    .subscribe(onNext: { [weak self, weak tableView] status in
                        guard let currentIndexPath = tableView?.indexPath(for: cell) else { return }
789
                        guard let transferId = item.daemonId else { return }
790 791
                        guard let model = self?.viewModel else { return }
                        self?.log.info("Transfer status change from: \(item.lastTransferStatus.description) to: \(status.description) for transferId: \(transferId) cell row: \(currentIndexPath.row)")
792
                        if item.bubblePosition() == .sent && item.shouldDisplayTransferedImage {
793
                            cell.displayTransferedImage(message: item, conversationID: model.conversation.value.conversationId)
794
                        } else {
795
                            self?.changeTransferStatus(cell, currentIndexPath, status, item, model)
796 797
                            cell.stopProgressMonitor()
                        }
798 799
                        item.lastTransferStatus = status
                        item.initialTransferStatus = status
800
                        tableView?.reloadData()
801 802 803 804
                    })
                    .disposed(by: cell.disposeBag)

                cell.cancelButton.rx.tap
805
                    .subscribe(onNext: { [weak self, weak tableView] _ in
806
                        guard let transferId = item.daemonId else { return }
807 808
                        self?.log.info("canceling transferId \(transferId)")
                        _ = self?.viewModel.cancelTransfer(transferId: transferId)
809 810 811
                        item.initialTransferStatus = .canceled
                        item.message.transferStatus = .canceled
                        cell.stopProgressMonitor()
812
                        tableView?.reloadData()
813 814 815
                    })
                    .disposed(by: cell.disposeBag)

816 817
                if item.bubblePosition() == .received {
                    cell.acceptButton?.rx.tap
818
                        .subscribe(onNext: { [weak self, weak tableView] _ in
819
                            guard let transferId = item.daemonId else { return }
820 821 822
                            self?.log.info("accepting transferId \(transferId)")
                            if self?.viewModel.acceptTransfer(transferId: transferId, interactionID: item.messageId, messageContent: &item.message.content) != .success {
                                _ = self?.viewModel.cancelTransfer(transferId: transferId)
823 824 825
                                item.initialTransferStatus = .canceled
                                item.message.transferStatus = .canceled
                                cell.stopProgressMonitor()
826
                                tableView?.reloadData()
827
                            }
828 829 830 831 832 833 834
                        })
                        .disposed(by: cell.disposeBag)

                    if item.message.transferStatus == .success {
                        self.addShareAction(cell: cell, item: item)
                    }
                }
835 836
            }

837
            return cell
Thibault Wittemberg's avatar
Thibault Wittemberg committed
838 839 840 841
        }
        return tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellSent.self)
    }
}
842 843
// swiftlint:enable type_body_length
// swiftlint:enable file_length