ChatViewController.swift 81 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945
  1. import MapKit
  2. import QuickLook
  3. import UIKit
  4. import InputBarAccessoryView
  5. import AVFoundation
  6. import DcCore
  7. import SDWebImage
  8. class ChatViewController: UITableViewController {
  9. var dcContext: DcContext
  10. private var draftMessage: DcMsg?
  11. let outgoingAvatarOverlap: CGFloat = 17.5
  12. let loadCount = 30
  13. let chatId: Int
  14. var messageIds: [Int] = []
  15. var msgChangedObserver: NSObjectProtocol?
  16. var incomingMsgObserver: NSObjectProtocol?
  17. var ephemeralTimerModifiedObserver: NSObjectProtocol?
  18. // isDismissing indicates whether the ViewController is/was about to dismissed.
  19. // The VC can be dismissed by pressing back '<' or by a swipe-to-dismiss gesture.
  20. // The latter is cancelable and leads to viewWillAppear is called in case the gesture is cancelled
  21. // We need the flag to handle that special case correctly in viewWillAppear
  22. private var isDismissing = false
  23. private var isInitial = true
  24. private var ignoreInputBarChange = false
  25. private var isVisibleToUser: Bool = false
  26. private var keepKeyboard: Bool = false
  27. lazy var isGroupChat: Bool = {
  28. return dcContext.getChat(chatId: chatId).isGroup
  29. }()
  30. lazy var draft: DraftModel = {
  31. let draft = DraftModel(dcContext: dcContext, chatId: chatId)
  32. return draft
  33. }()
  34. // search related
  35. var activateSearchOnAppear: Bool = false
  36. private var isSearchActive: Bool = false
  37. private var searchMessageIds: [Int] = []
  38. private var searchResultIndex: Int = 0
  39. private var debounceTimer: Timer?
  40. lazy var searchController: UISearchController = {
  41. let searchController = UISearchController(searchResultsController: nil)
  42. searchController.obscuresBackgroundDuringPresentation = false
  43. searchController.searchBar.placeholder = String.localized("search")
  44. searchController.searchBar.delegate = self
  45. searchController.searchResultsUpdater = self
  46. searchController.searchBar.inputAccessoryView = messageInputBar
  47. searchController.searchBar.autocorrectionType = .yes
  48. searchController.searchBar.keyboardType = .default
  49. searchController.searchBar.isHidden = true
  50. return searchController
  51. }()
  52. public lazy var searchAccessoryBar: ChatSearchAccessoryBar = {
  53. let view = ChatSearchAccessoryBar()
  54. view.delegate = self
  55. view.translatesAutoresizingMaskIntoConstraints = false
  56. view.isEnabled = false
  57. return view
  58. }()
  59. /// The `InputBarAccessoryView` used as the `inputAccessoryView` in the view controller.
  60. open var messageInputBar = ChatInputBar()
  61. lazy var draftArea: DraftArea = {
  62. let view = DraftArea()
  63. view.translatesAutoresizingMaskIntoConstraints = false
  64. view.delegate = self
  65. view.inputBarAccessoryView = messageInputBar
  66. return view
  67. }()
  68. public lazy var editingBar: ChatEditingBar = {
  69. let view = ChatEditingBar()
  70. view.delegate = self
  71. view.translatesAutoresizingMaskIntoConstraints = false
  72. return view
  73. }()
  74. public lazy var contactRequestBar: ChatContactRequestBar = {
  75. let chat = dcContext.getChat(chatId: chatId)
  76. let view = ChatContactRequestBar(useDeleteButton: chat.isGroup && !chat.isMailinglist)
  77. view.delegate = self
  78. view.translatesAutoresizingMaskIntoConstraints = false
  79. return view
  80. }()
  81. open override var shouldAutorotate: Bool {
  82. return false
  83. }
  84. private weak var timer: Timer?
  85. lazy var navBarTap: UITapGestureRecognizer = {
  86. UITapGestureRecognizer(target: self, action: #selector(chatProfilePressed))
  87. }()
  88. private var locationStreamingItem: UIBarButtonItem = {
  89. let indicator = LocationStreamingIndicator()
  90. return UIBarButtonItem(customView: indicator)
  91. }()
  92. private lazy var muteItem: UIBarButtonItem = {
  93. let imageView = UIImageView()
  94. imageView.tintColor = DcColors.defaultTextColor
  95. imageView.image = #imageLiteral(resourceName: "volume_off").withRenderingMode(.alwaysTemplate)
  96. imageView.translatesAutoresizingMaskIntoConstraints = false
  97. imageView.heightAnchor.constraint(equalToConstant: 20).isActive = true
  98. imageView.widthAnchor.constraint(equalToConstant: 20).isActive = true
  99. return UIBarButtonItem(customView: imageView)
  100. }()
  101. private lazy var ephemeralMessageItem: UIBarButtonItem = {
  102. let imageView = UIImageView()
  103. imageView.tintColor = DcColors.defaultTextColor
  104. imageView.image = #imageLiteral(resourceName: "ephemeral_timer").withRenderingMode(.alwaysTemplate)
  105. imageView.translatesAutoresizingMaskIntoConstraints = false
  106. imageView.heightAnchor.constraint(equalToConstant: 20).isActive = true
  107. imageView.widthAnchor.constraint(equalToConstant: 20).isActive = true
  108. return UIBarButtonItem(customView: imageView)
  109. }()
  110. private lazy var initialsBadge: InitialsBadge = {
  111. let badge: InitialsBadge
  112. badge = InitialsBadge(size: 28, accessibilityLabel: String.localized("menu_view_profile"))
  113. badge.setLabelFont(UIFont.systemFont(ofSize: 14))
  114. badge.accessibilityTraits = .button
  115. return badge
  116. }()
  117. private lazy var badgeItem: UIBarButtonItem = {
  118. return UIBarButtonItem(customView: initialsBadge)
  119. }()
  120. private lazy var contextMenu: ContextMenuProvider = {
  121. let copyItem = ContextMenuProvider.ContextMenuItem(
  122. title: String.localized("global_menu_edit_copy_desktop"),
  123. imageName: "doc.on.doc",
  124. action: #selector(BaseMessageCell.messageCopy),
  125. onPerform: { [weak self] indexPath in
  126. guard let self = self else { return }
  127. let id = self.messageIds[indexPath.row]
  128. let msg = self.dcContext.getMessage(id: id)
  129. let pasteboard = UIPasteboard.general
  130. if msg.type == DC_MSG_TEXT {
  131. pasteboard.string = msg.text
  132. } else {
  133. pasteboard.string = msg.summary(chars: 10000000)
  134. }
  135. }
  136. )
  137. let infoItem = ContextMenuProvider.ContextMenuItem(
  138. title: String.localized("info"),
  139. imageName: "info",
  140. action: #selector(BaseMessageCell.messageInfo),
  141. onPerform: { [weak self] indexPath in
  142. guard let self = self else { return }
  143. let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
  144. let msgViewController = MessageInfoViewController(dcContext: self.dcContext, message: msg)
  145. if let ctrl = self.navigationController {
  146. ctrl.pushViewController(msgViewController, animated: true)
  147. }
  148. }
  149. )
  150. let deleteItem = ContextMenuProvider.ContextMenuItem(
  151. title: String.localized("delete"),
  152. imageName: "trash",
  153. isDestructive: true,
  154. action: #selector(BaseMessageCell.messageDelete),
  155. onPerform: { [weak self] indexPath in
  156. DispatchQueue.main.async { [weak self] in
  157. guard let self = self else { return }
  158. self.tableView.becomeFirstResponder()
  159. let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
  160. self.askToDeleteMessage(id: msg.id)
  161. }
  162. }
  163. )
  164. let forwardItem = ContextMenuProvider.ContextMenuItem(
  165. title: String.localized("forward"),
  166. imageName: "ic_forward_white_36pt",
  167. action: #selector(BaseMessageCell.messageForward),
  168. onPerform: { [weak self] indexPath in
  169. guard let self = self else { return }
  170. let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
  171. RelayHelper.sharedInstance.setForwardMessage(messageId: msg.id)
  172. self.navigationController?.popViewController(animated: true)
  173. }
  174. )
  175. let replyItem = ContextMenuProvider.ContextMenuItem(
  176. title: String.localized("notify_reply_button"),
  177. imageName: "ic_reply",
  178. action: #selector(BaseMessageCell.messageReply),
  179. onPerform: { indexPath in
  180. DispatchQueue.main.async { [weak self] in
  181. self?.replyToMessage(at: indexPath)
  182. }
  183. }
  184. )
  185. let selectMoreItem = ContextMenuProvider.ContextMenuItem(
  186. title: String.localized("select_more"),
  187. imageName: "checkmark.circle",
  188. action: #selector(BaseMessageCell.messageSelectMore),
  189. onPerform: { indexPath in
  190. DispatchQueue.main.async { [weak self] in
  191. guard let self = self else { return }
  192. let messageId = self.messageIds[indexPath.row]
  193. self.setEditing(isEditing: true, selectedAtIndexPath: indexPath)
  194. }
  195. }
  196. )
  197. let config = ContextMenuProvider()
  198. if #available(iOS 13.0, *), !disableWriting {
  199. let mainContextMenu = ContextMenuProvider.ContextMenuItem(submenuitems: [replyItem, forwardItem, infoItem, copyItem, deleteItem])
  200. config.setMenu([mainContextMenu, selectMoreItem])
  201. } else if !disableWriting {
  202. config.setMenu([forwardItem, infoItem, copyItem, deleteItem, selectMoreItem])
  203. } else {
  204. config.setMenu([forwardItem, infoItem, copyItem, deleteItem])
  205. }
  206. return config
  207. }()
  208. /// The `BasicAudioController` controll the AVAudioPlayer state (play, pause, stop) and update audio cell UI accordingly.
  209. private lazy var audioController = AudioController(dcContext: dcContext, chatId: chatId, delegate: self)
  210. private var disableWriting: Bool
  211. var showCustomNavBar = true
  212. var highlightedMsg: Int?
  213. private lazy var mediaPicker: MediaPicker? = {
  214. let mediaPicker = MediaPicker(navigationController: navigationController)
  215. mediaPicker.delegate = self
  216. return mediaPicker
  217. }()
  218. var emptyStateView: EmptyStateLabel = {
  219. let view = EmptyStateLabel()
  220. view.isHidden = true
  221. return view
  222. }()
  223. init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) {
  224. let dcChat = dcContext.getChat(chatId: chatId)
  225. self.dcContext = dcContext
  226. self.chatId = chatId
  227. self.disableWriting = !dcChat.canSend
  228. self.highlightedMsg = highlightedMsg
  229. super.init(nibName: nil, bundle: nil)
  230. hidesBottomBarWhenPushed = true
  231. }
  232. required init?(coder _: NSCoder) {
  233. fatalError("init(coder:) has not been implemented")
  234. }
  235. override func loadView() {
  236. let inputBar = self.disableWriting && !dcContext.getChat(chatId: chatId).isContactRequest ? nil : messageInputBar
  237. self.tableView = ChatTableView(messageInputBar: inputBar)
  238. self.tableView.delegate = self
  239. self.tableView.dataSource = self
  240. self.view = self.tableView
  241. }
  242. override func viewDidLoad() {
  243. super.viewDidLoad()
  244. tableView.register(TextMessageCell.self, forCellReuseIdentifier: "text")
  245. tableView.register(ImageTextCell.self, forCellReuseIdentifier: "image")
  246. tableView.register(FileTextCell.self, forCellReuseIdentifier: "file")
  247. tableView.register(InfoMessageCell.self, forCellReuseIdentifier: "info")
  248. tableView.register(AudioMessageCell.self, forCellReuseIdentifier: "audio")
  249. tableView.register(VideoInviteCell.self, forCellReuseIdentifier: "video_invite")
  250. tableView.rowHeight = UITableView.automaticDimension
  251. tableView.separatorStyle = .none
  252. tableView.keyboardDismissMode = .interactive
  253. tableView.contentInsetAdjustmentBehavior = .never
  254. navigationController?.setNavigationBarHidden(false, animated: false)
  255. navigationItem.backButtonTitle = String.localized("chat")
  256. navigationItem.searchController = searchController
  257. definesPresentationContext = true
  258. if !dcContext.isConfigured() {
  259. // TODO: display message about nothing being configured
  260. return
  261. }
  262. configureEmptyStateView()
  263. if !disableWriting {
  264. configureUIForWriting()
  265. } else if dcContext.getChat(chatId: chatId).isContactRequest {
  266. configureContactRequestBar()
  267. }
  268. loadMessages()
  269. }
  270. private func configureUIForWriting() {
  271. configureMessageInputBar()
  272. draft.parse(draftMsg: dcContext.getDraft(chatId: chatId))
  273. messageInputBar.inputTextView.text = draft.text
  274. configureDraftArea(draft: draft, animated: false)
  275. tableView.allowsMultipleSelectionDuringEditing = true
  276. }
  277. private func getTopInsetHeight() -> CGFloat {
  278. let navigationBarHeight = (navigationController?.navigationBar.bounds.height ?? 0)
  279. if let root = UIApplication.shared.keyWindow?.rootViewController {
  280. return navigationBarHeight + root.view.safeAreaInsets.top
  281. }
  282. return UIApplication.shared.statusBarFrame.height + navigationBarHeight
  283. }
  284. private func startTimer() {
  285. stopTimer()
  286. timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
  287. // reload table
  288. DispatchQueue.main.async { [weak self] in
  289. guard let self = self,
  290. let appDelegate = UIApplication.shared.delegate as? AppDelegate
  291. else { return }
  292. if appDelegate.appIsInForeground() {
  293. self.messageIds = self.getMessageIds()
  294. self.reloadData()
  295. } else {
  296. logger.warning("startTimer() must not be executed in background")
  297. }
  298. }
  299. }
  300. }
  301. private func stopTimer() {
  302. if let timer = timer {
  303. timer.invalidate()
  304. }
  305. timer = nil
  306. }
  307. private func configureEmptyStateView() {
  308. emptyStateView.addCenteredTo(parentView: view)
  309. }
  310. override func viewWillAppear(_ animated: Bool) {
  311. super.viewWillAppear(animated)
  312. // this will be removed in viewWillDisappear
  313. navigationController?.navigationBar.addGestureRecognizer(navBarTap)
  314. if showCustomNavBar {
  315. updateTitle(chat: dcContext.getChat(chatId: chatId))
  316. }
  317. if !isDismissing {
  318. self.tableView.becomeFirstResponder()
  319. if activateSearchOnAppear {
  320. DispatchQueue.main.async { [weak self] in
  321. self?.searchController.isActive = true
  322. self?.searchController.searchBar.isHidden = false
  323. self?.searchController.searchBar.becomeFirstResponder()
  324. }
  325. }
  326. var bottomInsets = self.messageInputBar.intrinsicContentSize.height + self.messageInputBar.keyboardHeight
  327. if UIApplication.shared.statusBarOrientation.isLandscape,
  328. let root = UIApplication.shared.keyWindow?.rootViewController {
  329. // in landscape we need to take safeAreaInsets into account, in portrait they're already part of the keyboard height
  330. bottomInsets += root.view.safeAreaInsets.bottom
  331. }
  332. self.tableView.contentInset = UIEdgeInsets(top: self.getTopInsetHeight(),
  333. left: 0,
  334. bottom: bottomInsets,
  335. right: 0)
  336. if let msgId = self.highlightedMsg, self.messageIds.firstIndex(of: msgId) != nil {
  337. UIView.animate(withDuration: 0.1, delay: 0, options: .allowAnimatedContent, animations: { [weak self] in
  338. self?.scrollToMessage(msgId: msgId, animated: false)
  339. }, completion: { [weak self] finished in
  340. if finished {
  341. guard let self = self else { return }
  342. self.highlightedMsg = nil
  343. self.isInitial = false
  344. self.ignoreInputBarChange = false
  345. self.messageInputBar.scrollDownButton.isHidden = self.isLastRowVisible()
  346. }
  347. })
  348. } else {
  349. UIView.animate(withDuration: 0.1, delay: 0, options: .allowAnimatedContent, animations: { [weak self] in
  350. guard let self = self else { return }
  351. if self.isInitial {
  352. self.scrollToLastUnseenMessage()
  353. }
  354. }, completion: { [weak self] finished in
  355. guard let self = self else { return }
  356. if finished {
  357. self.isInitial = false
  358. self.ignoreInputBarChange = false
  359. }
  360. })
  361. }
  362. }
  363. isDismissing = false
  364. if RelayHelper.sharedInstance.isForwarding() {
  365. askToForwardMessage()
  366. } else if RelayHelper.sharedInstance.isMailtoHandling() {
  367. messageInputBar.inputTextView.text = RelayHelper.sharedInstance.mailtoDraft
  368. RelayHelper.sharedInstance.finishMailto()
  369. }
  370. prepareContextMenu()
  371. }
  372. override func viewDidAppear(_ animated: Bool) {
  373. super.viewDidAppear(animated)
  374. AppStateRestorer.shared.storeLastActiveChat(chatId: chatId)
  375. // things that do not affect the chatview
  376. // and are delayed after the view is displayed
  377. DispatchQueue.global(qos: .background).async { [weak self] in
  378. guard let self = self else { return }
  379. self.dcContext.marknoticedChat(chatId: self.chatId)
  380. }
  381. handleUserVisibility(isVisible: true)
  382. }
  383. override func viewWillDisappear(_ animated: Bool) {
  384. super.viewWillDisappear(animated)
  385. // the navigationController will be used when chatDetail is pushed, so we have to remove that gestureRecognizer
  386. navigationController?.navigationBar.removeGestureRecognizer(navBarTap)
  387. isDismissing = true
  388. }
  389. override func viewDidDisappear(_ animated: Bool) {
  390. super.viewDidDisappear(animated)
  391. isDismissing = false
  392. ignoreInputBarChange = true
  393. AppStateRestorer.shared.resetLastActiveChat()
  394. handleUserVisibility(isVisible: false)
  395. audioController.stopAnyOngoingPlaying()
  396. }
  397. override func willMove(toParent parent: UIViewController?) {
  398. super.willMove(toParent: parent)
  399. if parent == nil {
  400. // logger.debug("chat observer: remove")
  401. removeObservers()
  402. } else {
  403. // logger.debug("chat observer: setup")
  404. setupObservers()
  405. }
  406. }
  407. private func setupObservers() {
  408. let nc = NotificationCenter.default
  409. msgChangedObserver = nc.addObserver(
  410. forName: dcNotificationChanged,
  411. object: nil,
  412. queue: OperationQueue.main
  413. ) { [weak self] notification in
  414. guard let self = self else { return }
  415. if let ui = notification.userInfo {
  416. if self.disableWriting {
  417. // always refresh, as we can't check currently
  418. self.refreshMessages()
  419. } else if let id = ui["message_id"] as? Int {
  420. if id > 0 {
  421. self.updateMessage(id)
  422. } else {
  423. // change might be a deletion
  424. self.refreshMessages()
  425. }
  426. }
  427. if self.showCustomNavBar {
  428. self.updateTitle(chat: self.dcContext.getChat(chatId: self.chatId))
  429. }
  430. }
  431. }
  432. incomingMsgObserver = nc.addObserver(
  433. forName: dcNotificationIncoming,
  434. object: nil, queue: OperationQueue.main
  435. ) { [weak self] notification in
  436. guard let self = self else { return }
  437. if let ui = notification.userInfo {
  438. if self.chatId == ui["chat_id"] as? Int {
  439. if let id = ui["message_id"] as? Int {
  440. if id > 0 {
  441. self.insertMessage(self.dcContext.getMessage(id: id))
  442. }
  443. }
  444. self.updateTitle(chat: self.dcContext.getChat(chatId: self.chatId))
  445. }
  446. }
  447. }
  448. ephemeralTimerModifiedObserver = nc.addObserver(
  449. forName: dcEphemeralTimerModified,
  450. object: nil, queue: OperationQueue.main
  451. ) { [weak self] _ in
  452. guard let self = self else { return }
  453. self.updateTitle(chat: self.dcContext.getChat(chatId: self.chatId))
  454. }
  455. nc.addObserver(self,
  456. selector: #selector(applicationDidBecomeActive(_:)),
  457. name: UIApplication.didBecomeActiveNotification,
  458. object: nil)
  459. nc.addObserver(self,
  460. selector: #selector(applicationWillResignActive(_:)),
  461. name: UIApplication.willResignActiveNotification,
  462. object: nil)
  463. }
  464. private func removeObservers() {
  465. let nc = NotificationCenter.default
  466. if let msgChangedObserver = self.msgChangedObserver {
  467. nc.removeObserver(msgChangedObserver)
  468. }
  469. if let incomingMsgObserver = self.incomingMsgObserver {
  470. nc.removeObserver(incomingMsgObserver)
  471. }
  472. if let ephemeralTimerModifiedObserver = self.ephemeralTimerModifiedObserver {
  473. nc.removeObserver(ephemeralTimerModifiedObserver)
  474. }
  475. nc.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
  476. nc.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
  477. }
  478. override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  479. let lastSectionVisibleBeforeTransition = self.isLastRowVisible()
  480. coordinator.animate(
  481. alongsideTransition: { [weak self] _ in
  482. guard let self = self else { return }
  483. if self.showCustomNavBar {
  484. self.navigationItem.setRightBarButton(self.badgeItem, animated: true)
  485. }
  486. if lastSectionVisibleBeforeTransition {
  487. self.scrollToBottom(animated: false)
  488. }
  489. },
  490. completion: {[weak self] _ in
  491. guard let self = self else { return }
  492. self.updateTitle(chat: self.dcContext.getChat(chatId: self.chatId))
  493. if lastSectionVisibleBeforeTransition {
  494. DispatchQueue.main.async { [weak self] in
  495. self?.reloadData()
  496. self?.scrollToBottom(animated: false)
  497. }
  498. }
  499. }
  500. )
  501. super.viewWillTransition(to: size, with: coordinator)
  502. }
  503. @objc func applicationDidBecomeActive(_ notification: NSNotification) {
  504. if navigationController?.visibleViewController == self {
  505. handleUserVisibility(isVisible: true)
  506. }
  507. }
  508. @objc func applicationWillResignActive(_ notification: NSNotification) {
  509. if navigationController?.visibleViewController == self {
  510. handleUserVisibility(isVisible: false)
  511. }
  512. }
  513. func handleUserVisibility(isVisible: Bool) {
  514. isVisibleToUser = isVisible
  515. if isVisible {
  516. startTimer()
  517. markSeenMessagesInVisibleArea()
  518. } else {
  519. stopTimer()
  520. draft.save(context: dcContext)
  521. }
  522. }
  523. /// UITableView methods
  524. override func numberOfSections(in tableView: UITableView) -> Int {
  525. return 1
  526. }
  527. override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
  528. return messageIds.count
  529. }
  530. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  531. _ = handleUIMenu()
  532. messageInputBar.scrollDownButton.isHidden = isInitial || isLastRowVisible()
  533. let id = messageIds[indexPath.row]
  534. if id == DC_MSG_ID_DAYMARKER {
  535. let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
  536. if messageIds.count > indexPath.row + 1 {
  537. var nextMessageId = messageIds[indexPath.row + 1]
  538. if nextMessageId == DC_MSG_ID_MARKER1 && messageIds.count > indexPath.row + 2 {
  539. nextMessageId = messageIds[indexPath.row + 2]
  540. }
  541. let nextMessage = dcContext.getMessage(id: nextMessageId)
  542. cell.update(text: DateUtils.getDateString(date: nextMessage.sentDate))
  543. } else {
  544. cell.update(text: "ErrDaymarker")
  545. }
  546. return cell
  547. } else if id == DC_MSG_ID_MARKER1 {
  548. // unread messages marker
  549. let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
  550. let freshMsgsCount = self.messageIds.count - (indexPath.row + 1)
  551. cell.update(text: String.localized(stringID: "chat_n_new_messages", count: freshMsgsCount))
  552. return cell
  553. }
  554. let message = dcContext.getMessage(id: id)
  555. if message.isInfo {
  556. let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
  557. cell.update(text: message.text)
  558. return cell
  559. }
  560. let cell: BaseMessageCell
  561. switch Int32(message.type) {
  562. case DC_MSG_VIDEOCHAT_INVITATION:
  563. let videoInviteCell = tableView.dequeueReusableCell(withIdentifier: "video_invite", for: indexPath) as? VideoInviteCell ?? VideoInviteCell()
  564. videoInviteCell.update(dcContext: dcContext, msg: message)
  565. return videoInviteCell
  566. case DC_MSG_IMAGE, DC_MSG_GIF, DC_MSG_VIDEO, DC_MSG_STICKER:
  567. cell = tableView.dequeueReusableCell(withIdentifier: "image", for: indexPath) as? ImageTextCell ?? ImageTextCell()
  568. case DC_MSG_FILE:
  569. if message.isSetupMessage {
  570. cell = tableView.dequeueReusableCell(withIdentifier: "text", for: indexPath) as? TextMessageCell ?? TextMessageCell()
  571. message.text = String.localized("autocrypt_asm_click_body")
  572. } else {
  573. cell = tableView.dequeueReusableCell(withIdentifier: "file", for: indexPath) as? FileTextCell ?? FileTextCell()
  574. }
  575. case DC_MSG_AUDIO, DC_MSG_VOICE:
  576. let audioMessageCell: AudioMessageCell = tableView.dequeueReusableCell(withIdentifier: "audio",
  577. for: indexPath) as? AudioMessageCell ?? AudioMessageCell()
  578. audioController.update(audioMessageCell, with: message.id)
  579. cell = audioMessageCell
  580. default:
  581. cell = tableView.dequeueReusableCell(withIdentifier: "text", for: indexPath) as? TextMessageCell ?? TextMessageCell()
  582. }
  583. var showAvatar = isGroupChat && !message.isFromCurrentSender
  584. var showName = isGroupChat
  585. if message.overrideSenderName != nil {
  586. showAvatar = !message.isFromCurrentSender
  587. showName = true
  588. }
  589. cell.baseDelegate = self
  590. cell.update(dcContext: dcContext,
  591. msg: message,
  592. messageStyle: configureMessageStyle(for: message, at: indexPath),
  593. showAvatar: showAvatar,
  594. showName: showName,
  595. searchText: searchController.searchBar.text,
  596. highlight: !searchMessageIds.isEmpty && message.id == searchMessageIds[searchResultIndex])
  597. return cell
  598. }
  599. public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
  600. if !decelerate {
  601. markSeenMessagesInVisibleArea()
  602. }
  603. }
  604. public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
  605. markSeenMessagesInVisibleArea()
  606. }
  607. private func configureContactRequestBar() {
  608. messageInputBar.separatorLine.backgroundColor = DcColors.colorDisabled
  609. messageInputBar.setMiddleContentView(contactRequestBar, animated: false)
  610. messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
  611. messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
  612. messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
  613. messageInputBar.setStackViewItems([], forStack: .top, animated: false)
  614. }
  615. private func configureDraftArea(draft: DraftModel, animated: Bool = true) {
  616. draftArea.configure(draft: draft)
  617. if draft.isEditing {
  618. messageInputBar.setMiddleContentView(editingBar, animated: false)
  619. messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
  620. messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
  621. messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
  622. } else {
  623. messageInputBar.setMiddleContentView(messageInputBar.inputTextView, animated: false)
  624. messageInputBar.setLeftStackViewWidthConstant(to: 40, animated: false)
  625. messageInputBar.setRightStackViewWidthConstant(to: 40, animated: false)
  626. messageInputBar.padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 12)
  627. }
  628. if isSearchActive {
  629. messageInputBar.setStackViewItems([searchAccessoryBar, draftArea], forStack: .top, animated: false)
  630. } else {
  631. messageInputBar.setStackViewItems([draftArea], forStack: .top, animated: false)
  632. }
  633. }
  634. override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
  635. let swipeAction = UISwipeActionsConfiguration(actions: [])
  636. return swipeAction
  637. }
  638. override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
  639. let message = dcContext.getMessage(id: messageIds[indexPath.row])
  640. if disableWriting || message.isInfo || message.type == DC_MSG_VIDEOCHAT_INVITATION {
  641. return nil
  642. }
  643. let action = UIContextualAction(style: .normal, title: nil,
  644. handler: { [weak self] (_, _, completionHandler) in
  645. self?.keepKeyboard = true
  646. self?.replyToMessage(at: indexPath)
  647. completionHandler(true)
  648. })
  649. if #available(iOS 13.0, *) {
  650. action.image = UIImage(named: traitCollection.userInterfaceStyle == .light ? "ic_reply_black" : "ic_reply")
  651. action.backgroundColor = DcColors.chatBackgroundColor
  652. } else {
  653. action.image = UIImage(named: "ic_reply_black")
  654. action.backgroundColor = .systemBlue
  655. }
  656. action.image?.accessibilityTraits = .button
  657. action.image?.accessibilityLabel = String.localized("menu_reply")
  658. let configuration = UISwipeActionsConfiguration(actions: [action])
  659. return configuration
  660. }
  661. func replyToMessage(at indexPath: IndexPath) {
  662. let message = dcContext.getMessage(id: self.messageIds[indexPath.row])
  663. self.draft.setQuote(quotedMsg: message)
  664. self.configureDraftArea(draft: self.draft)
  665. self.messageInputBar.inputTextView.becomeFirstResponder()
  666. }
  667. func markSeenMessagesInVisibleArea() {
  668. if isVisibleToUser,
  669. let indexPaths = tableView.indexPathsForVisibleRows {
  670. let visibleMessagesIds = indexPaths.map { UInt32(messageIds[$0.row]) }
  671. if !visibleMessagesIds.isEmpty {
  672. DispatchQueue.global(qos: .background).async { [weak self] in
  673. self?.dcContext.markSeenMessages(messageIds: visibleMessagesIds)
  674. }
  675. }
  676. }
  677. }
  678. func markSeenMessage(id: Int) {
  679. if isVisibleToUser {
  680. DispatchQueue.global(qos: .background).async { [weak self] in
  681. self?.dcContext.markSeenMessages(messageIds: [UInt32(id)])
  682. }
  683. }
  684. }
  685. override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
  686. if tableView.isEditing {
  687. handleEditingBar()
  688. }
  689. }
  690. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  691. if tableView.isEditing {
  692. handleEditingBar()
  693. return
  694. }
  695. let messageId = messageIds[indexPath.row]
  696. let message = dcContext.getMessage(id: messageId)
  697. if message.isSetupMessage {
  698. didTapAsm(msg: message, orgText: "")
  699. } else if message.type == DC_MSG_FILE ||
  700. message.type == DC_MSG_AUDIO ||
  701. message.type == DC_MSG_VOICE {
  702. showMediaGalleryFor(message: message)
  703. } else if message.type == DC_MSG_VIDEOCHAT_INVITATION {
  704. if let url = NSURL(string: message.getVideoChatUrl()) {
  705. UIApplication.shared.open(url as URL)
  706. }
  707. }
  708. _ = handleUIMenu()
  709. }
  710. override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  711. messageInputBar.inputTextView.layer.borderColor = DcColors.colorDisabled.cgColor
  712. }
  713. func configureMessageStyle(for message: DcMsg, at indexPath: IndexPath) -> UIRectCorner {
  714. var corners: UIRectCorner = []
  715. if message.isFromCurrentSender {
  716. corners.formUnion(.topLeft)
  717. corners.formUnion(.bottomLeft)
  718. corners.formUnion(.topRight)
  719. } else {
  720. corners.formUnion(.topRight)
  721. corners.formUnion(.bottomRight)
  722. corners.formUnion(.topLeft)
  723. }
  724. return corners
  725. }
  726. private func getBackgroundColor(for currentMessage: DcMsg) -> UIColor {
  727. return currentMessage.isFromCurrentSender ? DcColors.messagePrimaryColor : DcColors.messageSecondaryColor
  728. }
  729. private func updateTitle(chat: DcChat) {
  730. let titleView = ChatTitleView()
  731. var subtitle = "ErrSubtitle"
  732. let chatContactIds = chat.getContactIds(dcContext)
  733. if chat.isMailinglist {
  734. subtitle = String.localized("mailing_list")
  735. } else if chat.isBroadcast {
  736. subtitle = String.localized(stringID: "n_recipients", count: chatContactIds.count)
  737. } else if chat.isGroup {
  738. subtitle = String.localized(stringID: "n_members", count: chatContactIds.count)
  739. } else if chat.isDeviceTalk {
  740. subtitle = String.localized("device_talk_subtitle")
  741. } else if chat.isSelfTalk {
  742. subtitle = String.localized("chat_self_talk_subtitle")
  743. } else if chatContactIds.count >= 1 {
  744. subtitle = dcContext.getContact(id: chatContactIds[0]).email
  745. }
  746. titleView.updateTitleView(title: chat.name, subtitle: subtitle)
  747. navigationItem.titleView = titleView
  748. if let image = chat.profileImage {
  749. initialsBadge.setImage(image)
  750. } else {
  751. initialsBadge.setName(chat.name)
  752. initialsBadge.setColor(chat.color)
  753. }
  754. initialsBadge.setVerified(chat.isProtected)
  755. var rightBarButtonItems = [badgeItem]
  756. if chat.isSendingLocations {
  757. rightBarButtonItems.append(locationStreamingItem)
  758. }
  759. if chat.isMuted {
  760. rightBarButtonItems.append(muteItem)
  761. }
  762. if dcContext.getChatEphemeralTimer(chatId: chat.id) > 0 {
  763. rightBarButtonItems.append(ephemeralMessageItem)
  764. }
  765. navigationItem.rightBarButtonItems = rightBarButtonItems
  766. }
  767. @objc
  768. private func refreshMessages() {
  769. self.messageIds = self.getMessageIds()
  770. let wasLastSectionVisible = self.isLastRowVisible()
  771. self.reloadData()
  772. if wasLastSectionVisible {
  773. self.scrollToBottom(animated: true)
  774. }
  775. self.showEmptyStateView(self.messageIds.isEmpty)
  776. }
  777. private func reloadData() {
  778. let selectredRows = tableView.indexPathsForSelectedRows
  779. tableView.reloadData()
  780. // There's an iOS bug, filling up the console output but which can be ignored: https://developer.apple.com/forums/thread/668295
  781. // [Assert] Attempted to call -cellForRowAtIndexPath: on the table view while it was in the process of updating its visible cells, which is not allowed.
  782. selectredRows?.forEach({ (selectedRow) in
  783. tableView.selectRow(at: selectedRow, animated: false, scrollPosition: .none)
  784. })
  785. }
  786. private func loadMessages() {
  787. // update message ids
  788. var msgIds = self.getMessageIds()
  789. let freshMsgsCount = self.dcContext.getUnreadMessages(chatId: self.chatId)
  790. if freshMsgsCount > 0 && msgIds.count >= freshMsgsCount {
  791. let index = msgIds.count - freshMsgsCount
  792. msgIds.insert(Int(DC_MSG_ID_MARKER1), at: index)
  793. }
  794. self.messageIds = msgIds
  795. self.showEmptyStateView(self.messageIds.isEmpty)
  796. self.reloadData()
  797. }
  798. private func isLastRowVisible() -> Bool {
  799. guard !messageIds.isEmpty else { return false }
  800. let lastIndexPath = IndexPath(item: messageIds.count - 1, section: 0)
  801. return tableView.indexPathsForVisibleRows?.contains(lastIndexPath) ?? false
  802. }
  803. private func scrollToBottom() {
  804. scrollToBottom(animated: true)
  805. }
  806. private func scrollToBottom(animated: Bool) {
  807. if !messageIds.isEmpty {
  808. DispatchQueue.main.async { [weak self] in
  809. guard let self = self else { return }
  810. let numberOfRows = self.tableView.numberOfRows(inSection: 0)
  811. if numberOfRows > 0 {
  812. self.tableView.scrollToRow(at: IndexPath(row: numberOfRows - 1, section: 0), at: .bottom, animated: animated)
  813. }
  814. }
  815. }
  816. }
  817. private func scrollToLastUnseenMessage() {
  818. DispatchQueue.main.async { [weak self] in
  819. guard let self = self else { return }
  820. if let markerMessageIndex = self.messageIds.index(of: Int(DC_MSG_ID_MARKER1)) {
  821. let indexPath = IndexPath(row: markerMessageIndex, section: 0)
  822. self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
  823. } else {
  824. // scroll to bottom
  825. let numberOfRows = self.tableView.numberOfRows(inSection: 0)
  826. if numberOfRows > 0 {
  827. self.tableView.scrollToRow(at: IndexPath(row: numberOfRows - 1, section: 0), at: .bottom, animated: false)
  828. }
  829. }
  830. }
  831. }
  832. private func scrollToMessage(msgId: Int, animated: Bool = true) {
  833. DispatchQueue.main.async { [weak self] in
  834. guard let self = self else { return }
  835. guard let index = self.messageIds.firstIndex(of: msgId) else {
  836. return
  837. }
  838. let indexPath = IndexPath(row: index, section: 0)
  839. self.tableView.scrollToRow(at: indexPath, at: .top, animated: animated)
  840. }
  841. }
  842. private func showEmptyStateView(_ show: Bool) {
  843. if show {
  844. let dcChat = dcContext.getChat(chatId: chatId)
  845. if dcChat.isGroup {
  846. if dcChat.isBroadcast {
  847. emptyStateView.text = String.localized("chat_new_broadcast_hint")
  848. } else if dcChat.isUnpromoted {
  849. emptyStateView.text = String.localized("chat_new_group_hint")
  850. } else {
  851. emptyStateView.text = String.localized("chat_no_messages")
  852. }
  853. } else if dcChat.isSelfTalk {
  854. emptyStateView.text = String.localized("saved_messages_explain")
  855. } else if dcChat.isDeviceTalk {
  856. emptyStateView.text = String.localized("device_talk_explain")
  857. } else {
  858. emptyStateView.text = String.localizedStringWithFormat(String.localized("chat_no_messages_hint"), dcChat.name, dcChat.name)
  859. }
  860. emptyStateView.isHidden = false
  861. } else {
  862. emptyStateView.isHidden = true
  863. }
  864. }
  865. private func getMessageIds() -> [Int] {
  866. return dcContext.getMessageIds(chatId: chatId)
  867. }
  868. @objc private func saveDraft() {
  869. draft.save(context: dcContext)
  870. }
  871. private func configureMessageInputBar() {
  872. messageInputBar.delegate = self
  873. messageInputBar.inputTextView.tintColor = DcColors.primary
  874. messageInputBar.inputTextView.placeholder = String.localized("chat_input_placeholder")
  875. messageInputBar.separatorLine.backgroundColor = DcColors.colorDisabled
  876. messageInputBar.inputTextView.tintColor = DcColors.primary
  877. messageInputBar.inputTextView.textColor = DcColors.defaultTextColor
  878. messageInputBar.backgroundView.backgroundColor = DcColors.chatBackgroundColor
  879. messageInputBar.backgroundColor = DcColors.chatBackgroundColor
  880. messageInputBar.inputTextView.backgroundColor = DcColors.inputFieldColor
  881. messageInputBar.inputTextView.placeholderTextColor = DcColors.placeholderColor
  882. messageInputBar.inputTextView.textContainerInset = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 38)
  883. messageInputBar.inputTextView.placeholderLabelInsets = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 38)
  884. messageInputBar.inputTextView.layer.borderColor = DcColors.colorDisabled.cgColor
  885. messageInputBar.inputTextView.layer.borderWidth = 1.0
  886. messageInputBar.inputTextView.layer.cornerRadius = 13.0
  887. messageInputBar.inputTextView.layer.masksToBounds = true
  888. messageInputBar.inputTextView.scrollIndicatorInsets = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
  889. configureInputBarItems()
  890. messageInputBar.inputTextView.delegate = self
  891. if let inputTextView = messageInputBar.inputTextView as? ChatInputTextView {
  892. inputTextView.imagePasteDelegate = self
  893. }
  894. messageInputBar.onScrollDownButtonPressed = scrollToBottom
  895. }
  896. private func evaluateInputBar(draft: DraftModel) {
  897. messageInputBar.sendButton.isEnabled = draft.canSend()
  898. }
  899. private func configureInputBarItems() {
  900. messageInputBar.setLeftStackViewWidthConstant(to: 40, animated: false)
  901. messageInputBar.setRightStackViewWidthConstant(to: 40, animated: false)
  902. let sendButtonImage = UIImage(named: "paper_plane")?.withRenderingMode(.alwaysTemplate)
  903. messageInputBar.sendButton.image = sendButtonImage
  904. messageInputBar.sendButton.accessibilityLabel = String.localized("menu_send")
  905. messageInputBar.sendButton.accessibilityTraits = .button
  906. messageInputBar.sendButton.title = nil
  907. messageInputBar.sendButton.tintColor = UIColor(white: 1, alpha: 1)
  908. messageInputBar.sendButton.layer.cornerRadius = 20
  909. messageInputBar.middleContentViewPadding = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10)
  910. // this adds a padding between textinputfield and send button
  911. messageInputBar.sendButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
  912. messageInputBar.sendButton.setSize(CGSize(width: 40, height: 40), animated: false)
  913. messageInputBar.padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 12)
  914. messageInputBar.shouldManageSendButtonEnabledState = false
  915. let leftItems = [
  916. InputBarButtonItem()
  917. .configure {
  918. $0.spacing = .fixed(0)
  919. let clipperIcon = #imageLiteral(resourceName: "ic_attach_file_36pt").withRenderingMode(.alwaysTemplate)
  920. $0.image = clipperIcon
  921. $0.tintColor = DcColors.primary
  922. $0.setSize(CGSize(width: 40, height: 40), animated: false)
  923. $0.accessibilityLabel = String.localized("menu_add_attachment")
  924. $0.accessibilityTraits = .button
  925. }.onSelected {
  926. $0.tintColor = UIColor.themeColor(light: .lightGray, dark: .darkGray)
  927. }.onDeselected {
  928. $0.tintColor = DcColors.primary
  929. }.onTouchUpInside { [weak self] _ in
  930. self?.clipperButtonPressed()
  931. }
  932. ]
  933. messageInputBar.setStackViewItems(leftItems, forStack: .left, animated: false)
  934. // This just adds some more flare
  935. messageInputBar.sendButton
  936. .onEnabled { item in
  937. UIView.animate(withDuration: 0.3, animations: {
  938. item.backgroundColor = DcColors.primary
  939. })}
  940. .onDisabled { item in
  941. UIView.animate(withDuration: 0.3, animations: {
  942. item.backgroundColor = DcColors.colorDisabled
  943. })}
  944. }
  945. @objc private func chatProfilePressed() {
  946. showChatDetail(chatId: chatId)
  947. }
  948. @objc private func clipperButtonPressed() {
  949. showClipperOptions()
  950. }
  951. private func showClipperOptions() {
  952. let alert = UIAlertController(title: nil, message: nil, preferredStyle: .safeActionSheet)
  953. let galleryAction = PhotoPickerAlertAction(title: String.localized("gallery"), style: .default, handler: galleryButtonPressed(_:))
  954. let cameraAction = PhotoPickerAlertAction(title: String.localized("camera"), style: .default, handler: cameraButtonPressed(_:))
  955. let documentAction = UIAlertAction(title: String.localized("files"), style: .default, handler: documentActionPressed(_:))
  956. let voiceMessageAction = UIAlertAction(title: String.localized("voice_message"), style: .default, handler: voiceMessageButtonPressed(_:))
  957. let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
  958. let locationStreamingAction = UIAlertAction(title: isLocationStreaming ? String.localized("stop_sharing_location") : String.localized("location"),
  959. style: isLocationStreaming ? .destructive : .default,
  960. handler: locationStreamingButtonPressed(_:))
  961. alert.addAction(cameraAction)
  962. alert.addAction(galleryAction)
  963. alert.addAction(documentAction)
  964. alert.addAction(voiceMessageAction)
  965. if let config = dcContext.getConfig("webrtc_instance"), !config.isEmpty {
  966. let videoChatInvitation = UIAlertAction(title: String.localized("videochat"), style: .default, handler: videoChatButtonPressed(_:))
  967. alert.addAction(videoChatInvitation)
  968. }
  969. if UserDefaults.standard.bool(forKey: "location_streaming") {
  970. alert.addAction(locationStreamingAction)
  971. }
  972. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  973. self.present(alert, animated: true, completion: {
  974. // unfortunately, voiceMessageAction.accessibilityHint does not work,
  975. // but this hack does the trick
  976. if UIAccessibility.isVoiceOverRunning {
  977. if let view = voiceMessageAction.value(forKey: "__representer") as? UIView {
  978. view.accessibilityHint = String.localized("a11y_voice_message_hint_ios")
  979. }
  980. }
  981. })
  982. }
  983. private func confirmationAlert(title: String, actionTitle: String, actionStyle: UIAlertAction.Style = .default, actionHandler: @escaping ((UIAlertAction) -> Void), cancelHandler: ((UIAlertAction) -> Void)? = nil) {
  984. let alert = UIAlertController(title: title,
  985. message: nil,
  986. preferredStyle: .safeActionSheet)
  987. alert.addAction(UIAlertAction(title: actionTitle, style: actionStyle, handler: actionHandler))
  988. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: cancelHandler ?? { _ in
  989. self.dismiss(animated: true, completion: nil)
  990. }))
  991. present(alert, animated: true, completion: nil)
  992. }
  993. private func askToDeleteChat() {
  994. let title = String.localized(stringID: "ask_delete_chat", count: 1)
  995. confirmationAlert(title: title, actionTitle: String.localized("delete"), actionStyle: .destructive,
  996. actionHandler: { [weak self] _ in
  997. guard let self = self else { return }
  998. // remove message observers early to avoid careless calls to dcContext methods
  999. self.removeObservers()
  1000. self.dcContext.deleteChat(chatId: self.chatId)
  1001. self.navigationController?.popViewController(animated: true)
  1002. })
  1003. }
  1004. private func askToChatWith(email: String) {
  1005. let contactId = self.dcContext.createContact(name: "", email: email)
  1006. if dcContext.getChatIdByContactId(contactId: contactId) != 0 {
  1007. self.dismiss(animated: true, completion: nil)
  1008. let chatId = self.dcContext.createChatByContactId(contactId: contactId)
  1009. self.showChat(chatId: chatId)
  1010. } else {
  1011. confirmationAlert(title: String.localizedStringWithFormat(String.localized("ask_start_chat_with"), email),
  1012. actionTitle: String.localized("start_chat"),
  1013. actionHandler: { _ in
  1014. self.dismiss(animated: true, completion: nil)
  1015. let chatId = self.dcContext.createChatByContactId(contactId: contactId)
  1016. self.showChat(chatId: chatId)})
  1017. }
  1018. }
  1019. private func askToDeleteMessage(id: Int) {
  1020. self.askToDeleteMessages(ids: [id])
  1021. }
  1022. private func askToDeleteMessages(ids: [Int]) {
  1023. let chat = dcContext.getChat(chatId: chatId)
  1024. let title = chat.isDeviceTalk ?
  1025. String.localized(stringID: "ask_delete_messages_simple", count: ids.count) :
  1026. String.localized(stringID: "ask_delete_messages", count: ids.count)
  1027. confirmationAlert(title: title, actionTitle: String.localized("delete"), actionStyle: .destructive,
  1028. actionHandler: { _ in
  1029. self.dcContext.deleteMessages(msgIds: ids)
  1030. if self.tableView.isEditing {
  1031. self.setEditing(isEditing: false)
  1032. }
  1033. })
  1034. }
  1035. private func askToForwardMessage() {
  1036. let chat = dcContext.getChat(chatId: self.chatId)
  1037. if chat.isSelfTalk {
  1038. RelayHelper.sharedInstance.forward(to: self.chatId)
  1039. refreshMessages()
  1040. } else {
  1041. confirmationAlert(title: String.localizedStringWithFormat(String.localized("ask_forward"), chat.name),
  1042. actionTitle: String.localized("menu_forward"),
  1043. actionHandler: { _ in
  1044. RelayHelper.sharedInstance.forward(to: self.chatId)
  1045. self.dismiss(animated: true, completion: nil)},
  1046. cancelHandler: { _ in
  1047. self.dismiss(animated: false, completion: nil)
  1048. self.navigationController?.popViewController(animated: true)})
  1049. }
  1050. }
  1051. // MARK: - coordinator
  1052. private func showChatDetail(chatId: Int) {
  1053. let chat = dcContext.getChat(chatId: chatId)
  1054. if !chat.isGroup {
  1055. if let contactId = chat.getContactIds(dcContext).first {
  1056. let contactDetailController = ContactDetailViewController(dcContext: dcContext, contactId: contactId)
  1057. navigationController?.pushViewController(contactDetailController, animated: true)
  1058. }
  1059. } else {
  1060. let groupChatDetailViewController = GroupChatDetailViewController(chatId: chatId, dcContext: dcContext)
  1061. navigationController?.pushViewController(groupChatDetailViewController, animated: true)
  1062. }
  1063. }
  1064. func showChat(chatId: Int, messageId: Int? = nil, animated: Bool = true) {
  1065. if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
  1066. appDelegate.appCoordinator.showChat(chatId: chatId, msgId: messageId, animated: animated, clearViewControllerStack: true)
  1067. }
  1068. }
  1069. private func showDocumentLibrary() {
  1070. mediaPicker?.showDocumentLibrary()
  1071. }
  1072. private func showVoiceMessageRecorder() {
  1073. mediaPicker?.showVoiceRecorder()
  1074. }
  1075. private func showCameraViewController() {
  1076. if AVCaptureDevice.authorizationStatus(for: .video) == .authorized {
  1077. self.mediaPicker?.showCamera()
  1078. } else {
  1079. AVCaptureDevice.requestAccess(for: .video, completionHandler: { [weak self] (granted: Bool) in
  1080. guard let self = self else { return }
  1081. if granted {
  1082. self.mediaPicker?.showCamera()
  1083. } else {
  1084. self.showCameraPermissionAlert()
  1085. }
  1086. })
  1087. }
  1088. }
  1089. private func showCameraPermissionAlert() {
  1090. DispatchQueue.main.async { [weak self] in
  1091. let alert = UIAlertController(title: String.localized("perm_required_title"),
  1092. message: String.localized("perm_ios_explain_access_to_camera_denied"),
  1093. preferredStyle: .alert)
  1094. if let appSettings = URL(string: UIApplication.openSettingsURLString) {
  1095. alert.addAction(UIAlertAction(title: String.localized("open_settings"), style: .default, handler: { _ in
  1096. UIApplication.shared.open(appSettings, options: [:], completionHandler: nil)}))
  1097. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .destructive, handler: nil))
  1098. }
  1099. self?.present(alert, animated: true, completion: nil)
  1100. }
  1101. }
  1102. private func showPhotoVideoLibrary(delegate: MediaPickerDelegate) {
  1103. mediaPicker?.showPhotoVideoLibrary()
  1104. }
  1105. private func showMediaGallery(currentIndex: Int, msgIds: [Int]) {
  1106. let betterPreviewController = PreviewController(dcContext: dcContext, type: .multi(msgIds, currentIndex))
  1107. let nav = UINavigationController(rootViewController: betterPreviewController)
  1108. nav.modalPresentationStyle = .fullScreen
  1109. navigationController?.present(nav, animated: true)
  1110. }
  1111. private func documentActionPressed(_ action: UIAlertAction) {
  1112. showDocumentLibrary()
  1113. }
  1114. private func voiceMessageButtonPressed(_ action: UIAlertAction) {
  1115. showVoiceMessageRecorder()
  1116. }
  1117. private func cameraButtonPressed(_ action: UIAlertAction) {
  1118. showCameraViewController()
  1119. }
  1120. private func galleryButtonPressed(_ action: UIAlertAction) {
  1121. showPhotoVideoLibrary(delegate: self)
  1122. }
  1123. private func locationStreamingButtonPressed(_ action: UIAlertAction) {
  1124. let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
  1125. if isLocationStreaming {
  1126. locationStreamingFor(seconds: 0)
  1127. } else {
  1128. let alert = UIAlertController(title: String.localized("title_share_location"), message: nil, preferredStyle: .safeActionSheet)
  1129. addDurationSelectionAction(to: alert, key: "share_location_for_5_minutes", duration: Time.fiveMinutes)
  1130. addDurationSelectionAction(to: alert, key: "share_location_for_30_minutes", duration: Time.thirtyMinutes)
  1131. addDurationSelectionAction(to: alert, key: "share_location_for_one_hour", duration: Time.oneHour)
  1132. addDurationSelectionAction(to: alert, key: "share_location_for_two_hours", duration: Time.twoHours)
  1133. addDurationSelectionAction(to: alert, key: "share_location_for_six_hours", duration: Time.sixHours)
  1134. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  1135. self.present(alert, animated: true, completion: nil)
  1136. }
  1137. }
  1138. private func videoChatButtonPressed(_ action: UIAlertAction) {
  1139. let chat = dcContext.getChat(chatId: chatId)
  1140. let alert = UIAlertController(title: String.localizedStringWithFormat(String.localized("videochat_invite_user_to_videochat"), chat.name),
  1141. message: String.localized("videochat_invite_user_hint"),
  1142. preferredStyle: .alert)
  1143. let cancel = UIAlertAction(title: String.localized("cancel"), style: .default, handler: nil)
  1144. let ok = UIAlertAction(title: String.localized("ok"),
  1145. style: .default,
  1146. handler: { _ in
  1147. DispatchQueue.global(qos: .userInitiated).async { [weak self] in
  1148. guard let self = self else { return }
  1149. let messageId = self.dcContext.sendVideoChatInvitation(chatId: self.chatId)
  1150. let inviteMessage = self.dcContext.getMessage(id: messageId)
  1151. if let url = NSURL(string: inviteMessage.getVideoChatUrl()) {
  1152. DispatchQueue.main.async {
  1153. UIApplication.shared.open(url as URL)
  1154. }
  1155. }
  1156. }})
  1157. alert.addAction(cancel)
  1158. alert.addAction(ok)
  1159. self.present(alert, animated: true, completion: nil)
  1160. }
  1161. private func addDurationSelectionAction(to alert: UIAlertController, key: String, duration: Int) {
  1162. let action = UIAlertAction(title: String.localized(key), style: .default, handler: { _ in
  1163. self.locationStreamingFor(seconds: duration)
  1164. })
  1165. alert.addAction(action)
  1166. }
  1167. private func locationStreamingFor(seconds: Int) {
  1168. guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
  1169. return
  1170. }
  1171. appDelegate.locationManager.shareLocation(chatId: self.chatId, duration: seconds)
  1172. }
  1173. func updateMessage(_ messageId: Int) {
  1174. if messageIds.firstIndex(where: { $0 == messageId }) != nil {
  1175. reloadData()
  1176. } else {
  1177. // new outgoing message
  1178. let msg = dcContext.getMessage(id: messageId)
  1179. if msg.state != DC_STATE_OUT_DRAFT,
  1180. msg.chatId == chatId {
  1181. if let newMsgMarkerIndex = messageIds.index(of: Int(DC_MSG_ID_MARKER1)) {
  1182. messageIds.remove(at: newMsgMarkerIndex)
  1183. }
  1184. insertMessage(msg)
  1185. }
  1186. }
  1187. }
  1188. func insertMessage(_ message: DcMsg) {
  1189. markSeenMessage(id: message.id)
  1190. let wasLastSectionVisible = isLastRowVisible()
  1191. messageIds.append(message.id)
  1192. emptyStateView.isHidden = true
  1193. reloadData()
  1194. if wasLastSectionVisible || message.isFromCurrentSender {
  1195. scrollToBottom(animated: true)
  1196. }
  1197. }
  1198. private func sendTextMessage(text: String, quoteMessage: DcMsg?) {
  1199. DispatchQueue.global().async {
  1200. let message = self.dcContext.newMessage(viewType: DC_MSG_TEXT)
  1201. message.text = text
  1202. if let quoteMessage = quoteMessage {
  1203. message.quoteMessage = quoteMessage
  1204. }
  1205. self.dcContext.sendMessage(chatId: self.chatId, message: message)
  1206. }
  1207. }
  1208. private func stageDocument(url: NSURL) {
  1209. keepKeyboard = true
  1210. self.draft.setAttachment(viewType: DC_MSG_FILE, path: url.relativePath)
  1211. self.configureDraftArea(draft: self.draft)
  1212. self.messageInputBar.inputTextView.becomeFirstResponder()
  1213. }
  1214. private func stageVideo(url: NSURL) {
  1215. keepKeyboard = true
  1216. DispatchQueue.main.async {
  1217. self.draft.setAttachment(viewType: DC_MSG_VIDEO, path: url.relativePath)
  1218. self.configureDraftArea(draft: self.draft)
  1219. self.messageInputBar.inputTextView.becomeFirstResponder()
  1220. }
  1221. }
  1222. private func stageImage(url: NSURL) {
  1223. keepKeyboard = true
  1224. DispatchQueue.global().async { [weak self] in
  1225. if let image = ImageFormat.loadImageFrom(url: url as URL) {
  1226. self?.stageImage(image)
  1227. }
  1228. }
  1229. }
  1230. private func stageImage(_ image: UIImage) {
  1231. DispatchQueue.global().async {
  1232. if let pathInDocDir = ImageFormat.saveImage(image: image) {
  1233. DispatchQueue.main.async {
  1234. if pathInDocDir.suffix(4).contains(".gif") {
  1235. self.draft.setAttachment(viewType: DC_MSG_GIF, path: pathInDocDir)
  1236. } else {
  1237. self.draft.setAttachment(viewType: DC_MSG_IMAGE, path: pathInDocDir)
  1238. }
  1239. self.configureDraftArea(draft: self.draft)
  1240. self.messageInputBar.inputTextView.becomeFirstResponder()
  1241. }
  1242. }
  1243. }
  1244. }
  1245. private func sendImage(_ image: UIImage, message: String? = nil) {
  1246. DispatchQueue.global().async {
  1247. if let path = ImageFormat.saveImage(image: image) {
  1248. self.sendAttachmentMessage(viewType: DC_MSG_IMAGE, filePath: path, message: message)
  1249. }
  1250. }
  1251. }
  1252. private func sendSticker(_ image: UIImage) {
  1253. DispatchQueue.global().async {
  1254. if let path = ImageFormat.saveImage(image: image) {
  1255. self.sendAttachmentMessage(viewType: DC_MSG_STICKER, filePath: path, message: nil)
  1256. }
  1257. }
  1258. }
  1259. private func sendAttachmentMessage(viewType: Int32, filePath: String, message: String? = nil, quoteMessage: DcMsg? = nil) {
  1260. let msg = dcContext.newMessage(viewType: viewType)
  1261. msg.setFile(filepath: filePath)
  1262. msg.text = (message ?? "").isEmpty ? nil : message
  1263. if quoteMessage != nil {
  1264. msg.quoteMessage = quoteMessage
  1265. }
  1266. dcContext.sendMessage(chatId: self.chatId, message: msg)
  1267. }
  1268. private func sendVoiceMessage(url: NSURL) {
  1269. DispatchQueue.global().async {
  1270. let msg = self.dcContext.newMessage(viewType: DC_MSG_VOICE)
  1271. msg.setFile(filepath: url.relativePath, mimeType: "audio/m4a")
  1272. self.dcContext.sendMessage(chatId: self.chatId, message: msg)
  1273. }
  1274. }
  1275. // MARK: - Context menu
  1276. private func prepareContextMenu() {
  1277. UIMenuController.shared.menuItems = contextMenu.menuItems
  1278. UIMenuController.shared.update()
  1279. }
  1280. override func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool {
  1281. let messageId = messageIds[indexPath.row]
  1282. return !(dcContext.getMessage(id: messageId).isInfo || messageId == DC_MSG_ID_MARKER1 || messageId == DC_MSG_ID_DAYMARKER)
  1283. }
  1284. override func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
  1285. return !tableView.isEditing && contextMenu.canPerformAction(action: action)
  1286. }
  1287. override func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) {
  1288. // handle standard actions here, but custom actions never trigger this. it still needs to be present for the menu to display, though.
  1289. contextMenu.performAction(action: action, indexPath: indexPath)
  1290. }
  1291. // context menu for iOS 13+
  1292. @available(iOS 13, *)
  1293. override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
  1294. let messageId = messageIds[indexPath.row]
  1295. if tableView.isEditing || dcContext.getMessage(id: messageId).isInfo || messageId == DC_MSG_ID_MARKER1 || messageId == DC_MSG_ID_DAYMARKER {
  1296. return nil
  1297. }
  1298. return UIContextMenuConfiguration(
  1299. identifier: nil,
  1300. previewProvider: nil,
  1301. actionProvider: { [weak self] _ in
  1302. self?.contextMenu.actionProvider(indexPath: indexPath)
  1303. }
  1304. )
  1305. }
  1306. func showMediaGalleryFor(indexPath: IndexPath) {
  1307. let messageId = messageIds[indexPath.row]
  1308. let message = dcContext.getMessage(id: messageId)
  1309. if message.type != DC_MSG_STICKER {
  1310. showMediaGalleryFor(message: message)
  1311. }
  1312. }
  1313. func showMediaGalleryFor(message: DcMsg) {
  1314. let msgIds = dcContext.getChatMedia(chatId: chatId, messageType: Int32(message.type), messageType2: 0, messageType3: 0)
  1315. let index = msgIds.firstIndex(of: message.id) ?? 0
  1316. showMediaGallery(currentIndex: index, msgIds: msgIds)
  1317. }
  1318. private func didTapAsm(msg: DcMsg, orgText: String) {
  1319. let inputDlg = UIAlertController(
  1320. title: String.localized("autocrypt_continue_transfer_title"),
  1321. message: String.localized("autocrypt_continue_transfer_please_enter_code"),
  1322. preferredStyle: .alert)
  1323. inputDlg.addTextField(configurationHandler: { (textField) in
  1324. textField.placeholder = msg.setupCodeBegin + ".."
  1325. textField.text = orgText
  1326. textField.keyboardType = UIKeyboardType.numbersAndPunctuation // allows entering spaces; decimalPad would require a mask to keep things readable
  1327. })
  1328. inputDlg.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  1329. let okAction = UIAlertAction(title: String.localized("ok"), style: .default, handler: { _ in
  1330. let textField = inputDlg.textFields![0]
  1331. let modText = textField.text ?? ""
  1332. let success = self.dcContext.continueKeyTransfer(msgId: msg.id, setupCode: modText)
  1333. let alert = UIAlertController(
  1334. title: String.localized("autocrypt_continue_transfer_title"),
  1335. message: String.localized(success ? "autocrypt_continue_transfer_succeeded" : "autocrypt_bad_setup_code"),
  1336. preferredStyle: .alert)
  1337. if success {
  1338. alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
  1339. } else {
  1340. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  1341. let retryAction = UIAlertAction(title: String.localized("autocrypt_continue_transfer_retry"), style: .default, handler: { _ in
  1342. self.didTapAsm(msg: msg, orgText: modText)
  1343. })
  1344. alert.addAction(retryAction)
  1345. alert.preferredAction = retryAction
  1346. }
  1347. self.navigationController?.present(alert, animated: true, completion: nil)
  1348. })
  1349. inputDlg.addAction(okAction)
  1350. inputDlg.preferredAction = okAction // without setting preferredAction, cancel become shown *bold* as the preferred action
  1351. navigationController?.present(inputDlg, animated: true, completion: nil)
  1352. }
  1353. func handleUIMenu() -> Bool {
  1354. if UIMenuController.shared.isMenuVisible {
  1355. UIMenuController.shared.setMenuVisible(false, animated: true)
  1356. return true
  1357. }
  1358. return false
  1359. }
  1360. func handleSelection(indexPath: IndexPath) -> Bool {
  1361. if tableView.isEditing {
  1362. if tableView.indexPathsForSelectedRows?.contains(indexPath) ?? false {
  1363. tableView.deselectRow(at: indexPath, animated: false)
  1364. } else {
  1365. tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
  1366. }
  1367. handleEditingBar()
  1368. return true
  1369. }
  1370. return false
  1371. }
  1372. func handleEditingBar() {
  1373. if let rows = tableView.indexPathsForSelectedRows,
  1374. !rows.isEmpty {
  1375. editingBar.isEnabled = true
  1376. } else {
  1377. editingBar.isEnabled = false
  1378. }
  1379. }
  1380. func setEditing(isEditing: Bool, selectedAtIndexPath: IndexPath? = nil) {
  1381. self.tableView.setEditing(isEditing, animated: true)
  1382. self.draft.isEditing = isEditing
  1383. self.configureDraftArea(draft: self.draft)
  1384. if let indexPath = selectedAtIndexPath {
  1385. _ = handleSelection(indexPath: indexPath)
  1386. }
  1387. }
  1388. }
  1389. // MARK: - BaseMessageCellDelegate
  1390. extension ChatViewController: BaseMessageCellDelegate {
  1391. @objc func actionButtonTapped(indexPath: IndexPath) {
  1392. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1393. return
  1394. }
  1395. let msg = dcContext.getMessage(id: messageIds[indexPath.row])
  1396. if msg.downloadState != DC_DOWNLOAD_DONE {
  1397. dcContext.downloadFullMessage(id: msg.id)
  1398. } else {
  1399. let fullMessageViewController = FullMessageViewController(dcContext: dcContext, messageId: msg.id)
  1400. navigationController?.pushViewController(fullMessageViewController, animated: true)
  1401. }
  1402. }
  1403. @objc func quoteTapped(indexPath: IndexPath) {
  1404. if handleSelection(indexPath: indexPath) { return }
  1405. _ = handleUIMenu()
  1406. let msg = dcContext.getMessage(id: messageIds[indexPath.row])
  1407. if let quoteMsg = msg.quoteMessage {
  1408. if self.chatId == quoteMsg.chatId {
  1409. scrollToMessage(msgId: quoteMsg.id)
  1410. } else {
  1411. showChat(chatId: quoteMsg.chatId, messageId: quoteMsg.id, animated: false)
  1412. }
  1413. }
  1414. }
  1415. @objc func textTapped(indexPath: IndexPath) {
  1416. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1417. return
  1418. }
  1419. let message = dcContext.getMessage(id: messageIds[indexPath.row])
  1420. if message.isSetupMessage {
  1421. didTapAsm(msg: message, orgText: "")
  1422. }
  1423. }
  1424. @objc func phoneNumberTapped(number: String, indexPath: IndexPath) {
  1425. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1426. return
  1427. }
  1428. let sanitizedNumber = number.filter("0123456789".contains)
  1429. if let phoneURL = URL(string: "tel://\(sanitizedNumber)") {
  1430. UIApplication.shared.open(phoneURL, options: [:], completionHandler: nil)
  1431. }
  1432. logger.debug("phone number tapped \(sanitizedNumber)")
  1433. }
  1434. @objc func commandTapped(command: String, indexPath: IndexPath) {
  1435. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1436. return
  1437. }
  1438. logger.debug("command tapped \(command)")
  1439. }
  1440. @objc func urlTapped(url: URL, indexPath: IndexPath) {
  1441. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1442. return
  1443. }
  1444. if Utils.isEmail(url: url) {
  1445. logger.debug("tapped on contact")
  1446. let email = Utils.getEmailFrom(url)
  1447. self.askToChatWith(email: email)
  1448. } else {
  1449. UIApplication.shared.open(url)
  1450. }
  1451. }
  1452. @objc func imageTapped(indexPath: IndexPath) {
  1453. if handleUIMenu() || handleSelection(indexPath: indexPath) {
  1454. return
  1455. }
  1456. showMediaGalleryFor(indexPath: indexPath)
  1457. }
  1458. @objc func avatarTapped(indexPath: IndexPath) {
  1459. let message = dcContext.getMessage(id: messageIds[indexPath.row])
  1460. let contactDetailController = ContactDetailViewController(dcContext: dcContext, contactId: message.fromContactId)
  1461. navigationController?.pushViewController(contactDetailController, animated: true)
  1462. }
  1463. }
  1464. // MARK: - MediaPickerDelegate
  1465. extension ChatViewController: MediaPickerDelegate {
  1466. func onVideoSelected(url: NSURL) {
  1467. stageVideo(url: url)
  1468. }
  1469. func onImageSelected(url: NSURL) {
  1470. stageImage(url: url)
  1471. }
  1472. func onImageSelected(image: UIImage) {
  1473. stageImage(image)
  1474. }
  1475. func onVoiceMessageRecorded(url: NSURL) {
  1476. sendVoiceMessage(url: url)
  1477. }
  1478. func onDocumentSelected(url: NSURL) {
  1479. stageDocument(url: url)
  1480. }
  1481. }
  1482. // MARK: - MessageInputBarDelegate
  1483. extension ChatViewController: InputBarAccessoryViewDelegate {
  1484. func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
  1485. keepKeyboard = true
  1486. let trimmedText = text.replacingOccurrences(of: "\u{FFFC}", with: "", options: .literal, range: nil)
  1487. .trimmingCharacters(in: .whitespacesAndNewlines)
  1488. if let filePath = draft.attachment, let viewType = draft.viewType {
  1489. switch viewType {
  1490. case DC_MSG_GIF, DC_MSG_IMAGE, DC_MSG_FILE, DC_MSG_VIDEO:
  1491. self.sendAttachmentMessage(viewType: viewType, filePath: filePath, message: trimmedText, quoteMessage: draft.quoteMessage)
  1492. default:
  1493. logger.warning("Unsupported viewType for drafted messages.")
  1494. }
  1495. } else if inputBar.inputTextView.images.isEmpty {
  1496. self.sendTextMessage(text: trimmedText, quoteMessage: draft.quoteMessage)
  1497. } else {
  1498. // only 1 attachment allowed for now, thus it takes the first one
  1499. self.sendImage(inputBar.inputTextView.images[0], message: trimmedText)
  1500. }
  1501. inputBar.inputTextView.text = String()
  1502. inputBar.inputTextView.attributedText = nil
  1503. draftArea.cancel()
  1504. }
  1505. func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String) {
  1506. draft.text = text
  1507. evaluateInputBar(draft: draft)
  1508. }
  1509. func inputBar(_ inputBar: InputBarAccessoryView, didChangeIntrinsicContentTo size: CGSize) {
  1510. if isDismissing {
  1511. return
  1512. }
  1513. var bottomInsets = size.height + messageInputBar.keyboardHeight
  1514. if UIApplication.shared.statusBarOrientation.isLandscape,
  1515. let root = UIApplication.shared.keyWindow?.rootViewController {
  1516. // in landscape we need to take safeAreaInsets into account, in portrait they're already part of the keyboard height
  1517. bottomInsets += root.view.safeAreaInsets.bottom
  1518. }
  1519. self.tableView.contentInset = UIEdgeInsets(top: self.getTopInsetHeight(),
  1520. left: 0,
  1521. bottom: bottomInsets,
  1522. right: 0)
  1523. if isLastRowVisible() && !tableView.isDragging && !tableView.isDecelerating && highlightedMsg == nil && !ignoreInputBarChange {
  1524. scrollToBottom()
  1525. }
  1526. }
  1527. }
  1528. // MARK: - DraftPreviewDelegate
  1529. extension ChatViewController: DraftPreviewDelegate {
  1530. func onCancelQuote() {
  1531. keepKeyboard = true
  1532. draft.setQuote(quotedMsg: nil)
  1533. configureDraftArea(draft: draft)
  1534. }
  1535. func onCancelAttachment() {
  1536. keepKeyboard = true
  1537. draft.setAttachment(viewType: nil, path: nil, mimetype: nil)
  1538. configureDraftArea(draft: draft)
  1539. evaluateInputBar(draft: draft)
  1540. }
  1541. func onAttachmentAdded() {
  1542. evaluateInputBar(draft: draft)
  1543. }
  1544. func onAttachmentTapped() {
  1545. if let attachmentPath = draft.attachment {
  1546. let attachmentURL = URL(fileURLWithPath: attachmentPath, isDirectory: false)
  1547. let previewController = PreviewController(dcContext: dcContext, type: .single(attachmentURL))
  1548. if #available(iOS 13.0, *), draft.viewType == DC_MSG_IMAGE || draft.viewType == DC_MSG_VIDEO {
  1549. previewController.setEditing(true, animated: true)
  1550. previewController.delegate = self
  1551. }
  1552. let nav = UINavigationController(rootViewController: previewController)
  1553. nav.modalPresentationStyle = .fullScreen
  1554. navigationController?.present(nav, animated: true)
  1555. }
  1556. }
  1557. }
  1558. // MARK: - ChatEditingDelegate
  1559. extension ChatViewController: ChatEditingDelegate {
  1560. func onDeletePressed() {
  1561. if let rows = tableView.indexPathsForSelectedRows {
  1562. let messageIdsToDelete = rows.compactMap { messageIds[$0.row] }
  1563. askToDeleteMessages(ids: messageIdsToDelete)
  1564. }
  1565. }
  1566. func onForwardPressed() {
  1567. if let rows = tableView.indexPathsForSelectedRows {
  1568. let messageIdsToForward = rows.compactMap { messageIds[$0.row] }
  1569. RelayHelper.sharedInstance.setForwardMessages(messageIds: messageIdsToForward)
  1570. self.navigationController?.popViewController(animated: true)
  1571. }
  1572. }
  1573. func onCancelPressed() {
  1574. setEditing(isEditing: false)
  1575. }
  1576. }
  1577. // MARK: - ChatSearchDelegate
  1578. extension ChatViewController: ChatSearchDelegate {
  1579. func onSearchPreviousPressed() {
  1580. logger.debug("onSearch Previous Pressed")
  1581. if searchResultIndex == 0 && !searchMessageIds.isEmpty {
  1582. searchResultIndex = searchMessageIds.count - 1
  1583. } else {
  1584. searchResultIndex -= 1
  1585. }
  1586. scrollToMessage(msgId: searchMessageIds[searchResultIndex])
  1587. searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
  1588. self.reloadData()
  1589. }
  1590. func onSearchNextPressed() {
  1591. logger.debug("onSearch Next Pressed")
  1592. if searchResultIndex == searchMessageIds.count - 1 {
  1593. searchResultIndex = 0
  1594. } else {
  1595. searchResultIndex += 1
  1596. }
  1597. scrollToMessage(msgId: searchMessageIds[searchResultIndex])
  1598. searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
  1599. self.reloadData()
  1600. }
  1601. }
  1602. // MARK: UISearchResultUpdating
  1603. extension ChatViewController: UISearchResultsUpdating {
  1604. func updateSearchResults(for searchController: UISearchController) {
  1605. logger.debug("searchbar: \(String(describing: searchController.searchBar.text))")
  1606. debounceTimer?.invalidate()
  1607. debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
  1608. let searchText = searchController.searchBar.text ?? ""
  1609. DispatchQueue.global().async {
  1610. let resultIds = self.dcContext.searchMessages(chatId: self.chatId, searchText: searchText)
  1611. DispatchQueue.main.async { [weak self] in
  1612. guard let self = self else { return }
  1613. self.searchMessageIds = resultIds
  1614. self.searchResultIndex = self.searchMessageIds.isEmpty ? 0 : self.searchMessageIds.count - 1
  1615. self.searchAccessoryBar.isEnabled = !resultIds.isEmpty
  1616. self.searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: self.searchResultIndex + 1)
  1617. if let lastId = resultIds.last {
  1618. self.scrollToMessage(msgId: lastId, animated: false)
  1619. }
  1620. self.reloadData()
  1621. }
  1622. }
  1623. }
  1624. }
  1625. }
  1626. // MARK: - UISearchBarDelegate
  1627. extension ChatViewController: UISearchBarDelegate {
  1628. func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
  1629. logger.debug("searchbar: searchBarShouldBeginEditing")
  1630. isSearchActive = true
  1631. configureDraftArea(draft: draft)
  1632. return true
  1633. }
  1634. func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
  1635. logger.debug("searchbar: searchBarTextDidEndEditing")
  1636. isSearchActive = false
  1637. configureDraftArea(draft: draft)
  1638. tableView.becomeFirstResponder()
  1639. }
  1640. func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
  1641. logger.debug("searchbar: searchBarTextDidEndEditing")
  1642. isSearchActive = false
  1643. configureDraftArea(draft: draft)
  1644. searchController.searchBar.isHidden = true
  1645. tableView.becomeFirstResponder()
  1646. reloadData()
  1647. }
  1648. }
  1649. // MARK: - ChatContactRequestBar
  1650. extension ChatViewController: ChatContactRequestDelegate {
  1651. func onAcceptRequest() {
  1652. dcContext.acceptChat(chatId: chatId)
  1653. let chat = dcContext.getChat(chatId: chatId)
  1654. if chat.isMailinglist {
  1655. messageInputBar.isHidden = true
  1656. } else {
  1657. configureUIForWriting()
  1658. }
  1659. }
  1660. func onBlockRequest() {
  1661. dcContext.blockChat(chatId: chatId)
  1662. self.navigationController?.popViewController(animated: true)
  1663. }
  1664. func onDeleteRequest() {
  1665. self.askToDeleteChat()
  1666. }
  1667. }
  1668. // MARK: - QLPreviewControllerDelegate
  1669. extension ChatViewController: QLPreviewControllerDelegate {
  1670. @available(iOS 13.0, *)
  1671. func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
  1672. return .updateContents
  1673. }
  1674. func previewController(_ controller: QLPreviewController, didUpdateContentsOf previewItem: QLPreviewItem) {
  1675. DispatchQueue.main.async { [weak self] in
  1676. guard let self = self else { return }
  1677. self.draftArea.reload(draft: self.draft)
  1678. }
  1679. }
  1680. }
  1681. // MARK: - AudioControllerDelegate
  1682. extension ChatViewController: AudioControllerDelegate {
  1683. func onAudioPlayFailed() {
  1684. let alert = UIAlertController(title: String.localized("error"),
  1685. message: String.localized("cannot_play_unsupported_file_type"),
  1686. preferredStyle: .safeActionSheet)
  1687. alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
  1688. self.present(alert, animated: true, completion: nil)
  1689. }
  1690. }
  1691. // MARK: - UITextViewDelegate
  1692. extension ChatViewController: UITextViewDelegate {
  1693. func textViewShouldEndEditing(_ textView: UITextView) -> Bool {
  1694. if keepKeyboard {
  1695. DispatchQueue.main.async { [weak self] in
  1696. self?.messageInputBar.inputTextView.becomeFirstResponder()
  1697. }
  1698. keepKeyboard = false
  1699. return false
  1700. }
  1701. return true
  1702. }
  1703. }
  1704. // MARK: - ChatInputTextViewPasteDelegate
  1705. extension ChatViewController: ChatInputTextViewPasteDelegate {
  1706. func onImagePasted(image: UIImage) {
  1707. sendSticker(image)
  1708. }
  1709. }