1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500 |
- import MapKit
- import QuickLook
- import UIKit
- import AVFoundation
- import DcCore
- import SDWebImage
- class ChatViewController: UITableViewController, UITableViewDropDelegate {
- var dcContext: DcContext
- let chatId: Int
- var messageIds: [Int] = []
- var msgChangedObserver: NSObjectProtocol?
- var incomingMsgObserver: NSObjectProtocol?
- var chatModifiedObserver: NSObjectProtocol?
- var ephemeralTimerModifiedObserver: NSObjectProtocol?
- private var isInitial = true
- private var isVisibleToUser: Bool = false
- private var keepKeyboard: Bool = false
- private var wasInputBarFirstResponder = false
- lazy var isGroupChat: Bool = {
- return dcContext.getChat(chatId: chatId).isGroup
- }()
- lazy var draft: DraftModel = {
- let draft = DraftModel(dcContext: dcContext, chatId: chatId)
- return draft
- }()
- private lazy var dropInteraction: ChatDropInteraction = {
- let dropInteraction = ChatDropInteraction()
- dropInteraction.delegate = self
- return dropInteraction
- }()
- // search related
- private var activateSearch: Bool = false
- private var searchMessageIds: [Int] = []
- private var searchResultIndex: Int = 0
- private var debounceTimer: Timer?
- lazy var searchController: UISearchController = {
- let searchController = UISearchController(searchResultsController: nil)
- searchController.obscuresBackgroundDuringPresentation = false
- searchController.searchBar.placeholder = String.localized("search")
- searchController.searchBar.delegate = self
- searchController.delegate = self
- searchController.searchResultsUpdater = self
- searchController.searchBar.inputAccessoryView = messageInputBar
- searchController.searchBar.autocorrectionType = .yes
- searchController.searchBar.keyboardType = .default
- return searchController
- }()
- public lazy var searchAccessoryBar: ChatSearchAccessoryBar = {
- let view = ChatSearchAccessoryBar()
- view.delegate = self
- view.translatesAutoresizingMaskIntoConstraints = false
- view.isEnabled = false
- return view
- }()
- public lazy var backgroundContainer: UIImageView = {
- let view = UIImageView()
- view.contentMode = .scaleAspectFill
- if let backgroundImageName = UserDefaults.standard.string(forKey: Constants.Keys.backgroundImageName) {
- view.sd_setImage(with: Utils.getBackgroundImageURL(name: backgroundImageName),
- placeholderImage: nil,
- options: [.retryFailed]) { [weak self] (_, error, _, _) in
- if let error = error {
- logger.error("Error loading background image: \(error.localizedDescription)" )
- DispatchQueue.main.async { [weak self] in
- self?.setDefaultBackgroundImage(view: view)
- }
- }
- }
- } else {
- setDefaultBackgroundImage(view: view)
- }
- return view
- }()
- /// The `InputBarAccessoryView` used as the `inputAccessoryView` in the view controller.
- lazy var messageInputBar: InputBarAccessoryView = {
- let inputBar = InputBarAccessoryView()
- return inputBar
- }()
- lazy var draftArea: DraftArea = {
- let view = DraftArea()
- view.translatesAutoresizingMaskIntoConstraints = false
- view.delegate = self
- view.inputBarAccessoryView = messageInputBar
- return view
- }()
- public lazy var editingBar: ChatEditingBar = {
- let view = ChatEditingBar()
- view.delegate = self
- view.translatesAutoresizingMaskIntoConstraints = false
- return view
- }()
- public lazy var contactRequestBar: ChatContactRequestBar = {
- let chat = dcContext.getChat(chatId: chatId)
- let view = ChatContactRequestBar(useDeleteButton: chat.isGroup && !chat.isMailinglist)
- view.delegate = self
- view.translatesAutoresizingMaskIntoConstraints = false
- return view
- }()
- open override var shouldAutorotate: Bool {
- return false
- }
- private weak var timer: Timer?
- lazy var navBarTap: UITapGestureRecognizer = {
- UITapGestureRecognizer(target: self, action: #selector(chatProfilePressed))
- }()
- private var locationStreamingItem: UIBarButtonItem = {
- let indicator = LocationStreamingIndicator()
- return UIBarButtonItem(customView: indicator)
- }()
- private lazy var muteItem: UIBarButtonItem = {
- let imageView = UIImageView()
- imageView.tintColor = DcColors.defaultTextColor
- imageView.image = #imageLiteral(resourceName: "volume_off").withRenderingMode(.alwaysTemplate)
- imageView.translatesAutoresizingMaskIntoConstraints = false
- imageView.heightAnchor.constraint(equalToConstant: 20).isActive = true
- imageView.widthAnchor.constraint(equalToConstant: 20).isActive = true
- return UIBarButtonItem(customView: imageView)
- }()
- private lazy var ephemeralMessageItem: UIBarButtonItem = {
- let imageView = UIImageView()
- imageView.tintColor = DcColors.defaultTextColor
- imageView.image = #imageLiteral(resourceName: "ephemeral_timer").withRenderingMode(.alwaysTemplate)
- imageView.translatesAutoresizingMaskIntoConstraints = false
- imageView.heightAnchor.constraint(equalToConstant: 20).isActive = true
- imageView.widthAnchor.constraint(equalToConstant: 20).isActive = true
- return UIBarButtonItem(customView: imageView)
- }()
- private lazy var initialsBadge: InitialsBadge = {
- let badge: InitialsBadge
- badge = InitialsBadge(size: 37, accessibilityLabel: String.localized("menu_view_profile"))
- badge.setLabelFont(UIFont.systemFont(ofSize: 14))
- badge.accessibilityTraits = .button
- return badge
- }()
- private lazy var badgeItem: UIBarButtonItem = {
- return UIBarButtonItem(customView: initialsBadge)
- }()
- private lazy var cancelButton: UIBarButtonItem = {
- let button = UIBarButtonItem.init(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel,
- target: self,
- action: #selector(onCancelPressed))
- return button
- }()
- private lazy var titleView: ChatTitleView = {
- return ChatTitleView()
- }()
- private lazy var dcChat: DcChat = {
- let chat = dcContext.getChat(chatId: chatId)
- return chat
- }()
- private lazy var contextMenu: ContextMenuProvider = {
- let config = ContextMenuProvider()
- if #available(iOS 13.0, *) {
- if dcChat.canSend {
- let mainMenu = ContextMenuProvider.ContextMenuItem(submenuitems: [replyItem, replyPrivatelyItem, forwardItem, infoItem, copyItem, deleteItem])
- config.setMenu([mainMenu, selectMoreItem])
- } else {
- config.setMenu([replyPrivatelyItem, forwardItem, infoItem, copyItem, deleteItem])
- }
- } else if dcChat.canSend { // skips some options on iOS <13 because of limited horizontal space (reply is still available by swiping)
- config.setMenu([forwardItem, infoItem, copyItem, deleteItem, selectMoreItem])
- } else {
- config.setMenu([forwardItem, infoItem, copyItem, deleteItem])
- }
- return config
- }()
- private lazy var copyItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("global_menu_edit_copy_desktop"),
- imageName: "doc.on.doc",
- action: #selector(BaseMessageCell.messageCopy),
- onPerform: { [weak self] indexPath in
- guard let self = self else { return }
- let id = self.messageIds[indexPath.row]
- self.copyToClipboard(ids: [id])
- }
- )
- }()
- private lazy var infoItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("info"),
- imageName: "info",
- action: #selector(BaseMessageCell.messageInfo),
- onPerform: { [weak self] indexPath in
- guard let self = self else { return }
- let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
- let msgViewController = MessageInfoViewController(dcContext: self.dcContext, message: msg)
- if let ctrl = self.navigationController {
- ctrl.pushViewController(msgViewController, animated: true)
- }
- }
- )
- }()
- private lazy var deleteItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("delete"),
- imageName: "trash",
- isDestructive: true,
- action: #selector(BaseMessageCell.messageDelete),
- onPerform: { [weak self] indexPath in
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.tableView.becomeFirstResponder()
- let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
- self.askToDeleteMessage(id: msg.id)
- }
- }
- )
- }()
- private lazy var forwardItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("forward"),
- imageName: "ic_forward_white_36pt",
- action: #selector(BaseMessageCell.messageForward),
- onPerform: { [weak self] indexPath in
- guard let self = self else { return }
- let msg = self.dcContext.getMessage(id: self.messageIds[indexPath.row])
- RelayHelper.shared.setForwardMessage(messageId: msg.id)
- self.navigationController?.popViewController(animated: true)
- }
- )
- }()
- private lazy var replyItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("notify_reply_button"),
- imageName: "arrowshape.turn.up.left.fill",
- action: #selector(BaseMessageCell.messageReply),
- onPerform: { [weak self] indexPath in
- self?.keepKeyboard = true
- DispatchQueue.main.async { [weak self] in
- self?.replyToMessage(at: indexPath)
- }
- }
- )
- }()
- private lazy var replyPrivatelyItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("reply_privately"),
- imageName: "arrowshape.turn.up.left",
- action: #selector(BaseMessageCell.messageReplyPrivately),
- onPerform: { [weak self] indexPath in
- guard let self = self else { return }
- self.replyPrivatelyToMessage(at: indexPath)
- }
- )
- }()
- private lazy var selectMoreItem: ContextMenuProvider.ContextMenuItem = {
- return ContextMenuProvider.ContextMenuItem(
- title: String.localized("select_more"),
- imageName: "checkmark.circle",
- action: #selector(BaseMessageCell.messageSelectMore),
- onPerform: { indexPath in
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- let messageId = self.messageIds[indexPath.row]
- self.setEditing(isEditing: true, selectedAtIndexPath: indexPath)
- if UIAccessibility.isVoiceOverRunning {
- self.forceVoiceOverFocussingCell(at: indexPath, postingFinished: nil)
- }
- }
- }
- )
- }()
- /// The `BasicAudioController` controll the AVAudioPlayer state (play, pause, stop) and update audio cell UI accordingly.
- private lazy var audioController = AudioController(dcContext: dcContext, chatId: chatId, delegate: self)
- private lazy var keyboardManager: KeyboardManager? = {
- let manager = KeyboardManager()
- return manager
- }()
- var highlightedMsg: Int?
- private lazy var mediaPicker: MediaPicker? = {
- let mediaPicker = MediaPicker(navigationController: navigationController)
- mediaPicker.delegate = self
- return mediaPicker
- }()
- var emptyStateView: EmptyStateLabel = {
- let view = EmptyStateLabel()
- view.isHidden = true
- return view
- }()
- init(dcContext: DcContext, chatId: Int, highlightedMsg: Int? = nil) {
- self.dcContext = dcContext
- self.chatId = chatId
- self.highlightedMsg = highlightedMsg
- super.init(nibName: nil, bundle: nil)
- hidesBottomBarWhenPushed = true
- }
- required init?(coder _: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- override func loadView() {
- super.loadView()
- self.tableView = ChatTableView(messageInputBar: messageInputBar)
- self.tableView.delegate = self
- self.tableView.dataSource = self
- self.view = self.tableView
- }
- override func viewDidLoad() {
- super.viewDidLoad()
- tableView.backgroundView = backgroundContainer
- tableView.register(TextMessageCell.self, forCellReuseIdentifier: "text")
- tableView.register(ImageTextCell.self, forCellReuseIdentifier: "image")
- tableView.register(FileTextCell.self, forCellReuseIdentifier: "file")
- tableView.register(InfoMessageCell.self, forCellReuseIdentifier: "info")
- tableView.register(AudioMessageCell.self, forCellReuseIdentifier: "audio")
- tableView.register(VideoInviteCell.self, forCellReuseIdentifier: "video_invite")
- tableView.register(WebxdcCell.self, forCellReuseIdentifier: "webxdc")
- tableView.rowHeight = UITableView.automaticDimension
- tableView.separatorStyle = .none
- tableView.keyboardDismissMode = .interactive
- navigationController?.setNavigationBarHidden(false, animated: false)
- if #available(iOS 13.0, *) {
- navigationController?.navigationBar.scrollEdgeAppearance = navigationController?.navigationBar.standardAppearance
- }
- navigationItem.backButtonTitle = String.localized("chat")
- definesPresentationContext = true
- // Binding to the tableView will enable interactive dismissal
- keyboardManager?.bind(to: tableView)
- keyboardManager?.on(event: .didChangeFrame) { [weak self] _ in
- guard let self = self else { return }
- if self.isInitial {
- self.isInitial = false
- return
- }
- if self.isLastRowVisible() && !self.tableView.isDragging && !self.tableView.isDecelerating && self.highlightedMsg == nil {
- self.scrollToBottom()
- }
- }.on(event: .willChangeFrame) { [weak self] _ in
- guard let self = self else { return }
- if self.isLastRowVisible() && !self.tableView.isDragging && !self.tableView.isDecelerating && self.highlightedMsg == nil && !self.isInitial {
- self.scrollToBottom()
- }
- }
- if !dcContext.isConfigured() {
- // TODO: display message about nothing being configured
- return
- }
- configureEmptyStateView()
- if dcChat.canSend {
- configureUIForWriting()
- } else if dcChat.isContactRequest {
- configureContactRequestBar()
- } else {
- messageInputBar.isHidden = true
- }
- loadMessages()
- }
- private func configureUIForWriting() {
- configureMessageInputBar()
- draft.parse(draftMsg: dcContext.getDraft(chatId: chatId))
- messageInputBar.inputTextView.text = draft.text
- configureDraftArea(draft: draft, animated: false)
- tableView.allowsMultipleSelectionDuringEditing = true
- tableView.dragInteractionEnabled = true
- tableView.dropDelegate = self
- }
- private func getTopInsetHeight() -> CGFloat {
- let navigationBarHeight = navigationController?.navigationBar.bounds.height ?? 0
- if let root = UIApplication.shared.keyWindow?.rootViewController {
- return navigationBarHeight + root.view.safeAreaInsets.top
- }
- return UIApplication.shared.statusBarFrame.height + navigationBarHeight
- }
- private func startTimer() {
- stopTimer()
- timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
- // reload table
- DispatchQueue.main.async { [weak self] in
- guard let self = self,
- let appDelegate = UIApplication.shared.delegate as? AppDelegate
- else { return }
-
- if appDelegate.appIsInForeground() {
- self.messageIds = self.dcContext.getChatMsgs(chatId: self.chatId)
- self.reloadData()
- } else {
- logger.warning("startTimer() must not be executed in background")
- }
- }
- }
- }
- public func activateSearchOnAppear() {
- activateSearch = true
- navigationItem.searchController = self.searchController
- }
- private func stopTimer() {
- if let timer = timer {
- timer.invalidate()
- }
- timer = nil
- }
- private func configureEmptyStateView() {
- emptyStateView.addCenteredTo(parentView: view)
- }
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- // this will be removed in viewWillDisappear
- navigationController?.navigationBar.addGestureRecognizer(navBarTap)
- updateTitle()
- tableView.becomeFirstResponder()
- if activateSearch {
- activateSearch = false
- DispatchQueue.main.async { [weak self] in
- self?.searchController.isActive = true
- }
- }
- if let msgId = self.highlightedMsg, self.messageIds.firstIndex(of: msgId) != nil {
- UIView.animate(withDuration: 0.1, delay: 0, options: .allowAnimatedContent, animations: { [weak self] in
- self?.scrollToMessage(msgId: msgId, animated: false)
- }, completion: { [weak self] finished in
- if finished {
- guard let self = self else { return }
- self.highlightedMsg = nil
- self.updateScrollDownButtonVisibility()
- }
- })
- } else {
- UIView.animate(withDuration: 0.1, delay: 0, options: .allowAnimatedContent, animations: { [weak self] in
- guard let self = self else { return }
- if self.isInitial {
- self.scrollToLastUnseenMessage()
- }
- }, completion: { [weak self] finished in
- guard let self = self else { return }
- if finished {
- self.updateScrollDownButtonVisibility()
- }
- })
- }
- if RelayHelper.shared.isForwarding() {
- askToForwardMessage()
- } else if RelayHelper.shared.isMailtoHandling() {
- messageInputBar.inputTextView.text = RelayHelper.shared.mailtoDraft
- RelayHelper.shared.finishMailto()
- }
- }
- override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- AppStateRestorer.shared.storeLastActiveChat(chatId: chatId)
- // things that do not affect the chatview
- // and are delayed after the view is displayed
- DispatchQueue.global(qos: .background).async { [weak self] in
- guard let self = self else { return }
- self.dcContext.marknoticedChat(chatId: self.chatId)
- }
- handleUserVisibility(isVisible: true)
- // this block ensures that if a swipe-to-dismiss gesture was cancelled, the UI recovers
- if wasInputBarFirstResponder {
- messageInputBar.inputTextView.becomeFirstResponder()
- } else {
- tableView.becomeFirstResponder()
- }
- }
- override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- // the navigationController will be used when chatDetail is pushed, so we have to remove that gestureRecognizer
- navigationController?.navigationBar.removeGestureRecognizer(navBarTap)
- wasInputBarFirstResponder = messageInputBar.inputTextView.isFirstResponder
- if !wasInputBarFirstResponder {
- tableView.resignFirstResponder()
- }
- }
- override func viewDidDisappear(_ animated: Bool) {
- super.viewDidDisappear(animated)
- AppStateRestorer.shared.resetLastActiveChat()
- handleUserVisibility(isVisible: false)
- audioController.stopAnyOngoingPlaying()
- messageInputBar.inputTextView.resignFirstResponder()
- wasInputBarFirstResponder = false
- }
- override func willMove(toParent parent: UIViewController?) {
- super.willMove(toParent: parent)
- if parent == nil {
- logger.debug(">>> ChatViewController - chat observer: remove")
- removeObservers()
- draft.save(context: dcContext)
- } else {
- logger.debug(">>> ChatViewController - chat observer: setup")
- setupObservers()
- }
- }
- override func didMove(toParent parent: UIViewController?) {
- super.didMove(toParent: parent)
- if parent == nil {
- keyboardManager = nil
- }
- }
- private func setupObservers() {
- let nc = NotificationCenter.default
- msgChangedObserver = nc.addObserver(
- forName: dcNotificationChanged,
- object: nil,
- queue: OperationQueue.main
- ) { [weak self] notification in
- guard let self = self else { return }
- if let ui = notification.userInfo {
- logger.debug(">>> msgChangedObserver: \(String(describing: ui["message_id"]))")
- if self.dcChat.canSend, let id = ui["message_id"] as? Int, id > 0 {
- let msg = self.dcContext.getMessage(id: id)
- if msg.isInfo,
- let parent = msg.parent,
- parent.type == DC_MSG_WEBXDC {
- self.refreshMessages()
- } else {
- self.updateMessage(msg)
- }
- } else {
- self.refreshMessages()
- DispatchQueue.main.async {
- self.updateScrollDownButtonVisibility()
- }
- }
- self.updateTitle()
- }
- }
- incomingMsgObserver = nc.addObserver(
- forName: dcNotificationIncoming,
- object: nil, queue: OperationQueue.main
- ) { [weak self] notification in
- guard let self = self else { return }
- if let ui = notification.userInfo {
- logger.debug(">>> incomingMsgObserver: \(String(describing: ui["chat_id"])) \(String(describing: ui["messageId"]))")
- if self.chatId == ui["chat_id"] as? Int {
- if let id = ui["message_id"] as? Int {
- if id > 0 {
- self.insertMessage(self.dcContext.getMessage(id: id))
- } else {
- logger.debug(">>> messageId \(id) is not > 0, message not inserted")
- }
- }
- self.updateTitle()
- }
- }
- }
- chatModifiedObserver = nc.addObserver(
- forName: dcNotificationChatModified,
- object: nil, queue: OperationQueue.main
- ) { [weak self] notification in
- guard let self = self else { return }
- if let ui = notification.userInfo, self.chatId == ui["chat_id"] as? Int {
- self.dcChat = self.dcContext.getChat(chatId: self.chatId)
- if self.dcChat.canSend {
- if self.messageInputBar.isHidden {
- self.configureUIForWriting()
- self.messageInputBar.isHidden = false
- }
- } else if !self.dcChat.isContactRequest {
- if !self.messageInputBar.isHidden {
- self.messageInputBar.isHidden = true
- }
- }
- }
- }
- ephemeralTimerModifiedObserver = nc.addObserver(
- forName: dcEphemeralTimerModified,
- object: nil, queue: OperationQueue.main
- ) { [weak self] _ in
- guard let self = self else { return }
- self.updateTitle()
- }
- nc.addObserver(self,
- selector: #selector(applicationDidBecomeActive(_:)),
- name: UIApplication.didBecomeActiveNotification,
- object: nil)
- nc.addObserver(self,
- selector: #selector(applicationWillResignActive(_:)),
- name: UIApplication.willResignActiveNotification,
- object: nil)
- }
-
- private func removeObservers() {
- let nc = NotificationCenter.default
- if let msgChangedObserver = self.msgChangedObserver {
- nc.removeObserver(msgChangedObserver)
- }
- if let incomingMsgObserver = self.incomingMsgObserver {
- nc.removeObserver(incomingMsgObserver)
- }
- if let chatModifiedObserver = self.chatModifiedObserver {
- nc.removeObserver(chatModifiedObserver)
- }
- if let ephemeralTimerModifiedObserver = self.ephemeralTimerModifiedObserver {
- nc.removeObserver(ephemeralTimerModifiedObserver)
- }
- nc.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
- nc.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
- }
- override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
- let lastSectionVisibleBeforeTransition = self.isLastRowVisible(checkTopCellPostion: true, checkBottomCellPosition: true, allowPartialVisibility: true)
- coordinator.animate(
- alongsideTransition: { [weak self] _ in
- guard let self = self else { return }
- self.navigationItem.setRightBarButton(self.badgeItem, animated: true)
- if lastSectionVisibleBeforeTransition {
- self.scrollToBottom(animated: false)
- }
- },
- completion: {[weak self] _ in
- guard let self = self else { return }
- self.updateTitle()
- if lastSectionVisibleBeforeTransition {
- DispatchQueue.main.async { [weak self] in
- self?.reloadData()
- self?.scrollToBottom(animated: false)
- }
- }
- }
- )
- super.viewWillTransition(to: size, with: coordinator)
- }
- @objc func applicationDidBecomeActive(_ notification: NSNotification) {
- if navigationController?.visibleViewController == self {
- handleUserVisibility(isVisible: true)
- }
- }
- @objc func applicationWillResignActive(_ notification: NSNotification) {
- if navigationController?.visibleViewController == self {
- handleUserVisibility(isVisible: false)
- draft.save(context: dcContext)
- }
- }
-
- func handleUserVisibility(isVisible: Bool) {
- isVisibleToUser = isVisible
- if isVisible {
- startTimer()
- markSeenMessagesInVisibleArea()
- } else {
- stopTimer()
- }
- }
- /// UITableView methods
- override func numberOfSections(in tableView: UITableView) -> Int {
- return 1
- }
- override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
- return messageIds.count
- }
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- _ = handleUIMenu()
- let id = messageIds[indexPath.row]
- if id == DC_MSG_ID_DAYMARKER {
- let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
- if messageIds.count > indexPath.row + 1 {
- var nextMessageId = messageIds[indexPath.row + 1]
- if nextMessageId == DC_MSG_ID_MARKER1 && messageIds.count > indexPath.row + 2 {
- nextMessageId = messageIds[indexPath.row + 2]
- }
- let nextMessage = dcContext.getMessage(id: nextMessageId)
- cell.update(text: DateUtils.getDateString(date: nextMessage.sentDate), weight: .bold)
- } else {
- cell.update(text: "ErrDaymarker")
- }
- return cell
- } else if id == DC_MSG_ID_MARKER1 {
- // unread messages marker
- let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
- let freshMsgsCount = self.messageIds.count - (indexPath.row + 1)
- cell.update(text: String.localized(stringID: "chat_n_new_messages", count: freshMsgsCount))
- return cell
- }
-
- let message = dcContext.getMessage(id: id)
- if message.isInfo {
- let cell = tableView.dequeueReusableCell(withIdentifier: "info", for: indexPath) as? InfoMessageCell ?? InfoMessageCell()
- cell.showSelectionBackground(tableView.isEditing)
- if message.infoType == DC_INFO_WEBXDC_INFO_MESSAGE, let parent = message.parent {
- cell.update(text: message.text, image: parent.getWebxdcPreviewImage())
- } else {
- cell.update(text: message.text)
- }
- return cell
- }
- let cell: BaseMessageCell
- switch Int32(message.type) {
- case DC_MSG_VIDEOCHAT_INVITATION:
- let videoInviteCell = tableView.dequeueReusableCell(withIdentifier: "video_invite", for: indexPath) as? VideoInviteCell ?? VideoInviteCell()
- videoInviteCell.showSelectionBackground(tableView.isEditing)
- videoInviteCell.update(dcContext: dcContext, msg: message)
- return videoInviteCell
- case DC_MSG_IMAGE, DC_MSG_GIF, DC_MSG_VIDEO, DC_MSG_STICKER:
- cell = tableView.dequeueReusableCell(withIdentifier: "image", for: indexPath) as? ImageTextCell ?? ImageTextCell()
- case DC_MSG_FILE:
- if message.isSetupMessage {
- cell = tableView.dequeueReusableCell(withIdentifier: "text", for: indexPath) as? TextMessageCell ?? TextMessageCell()
- message.text = String.localized("autocrypt_asm_click_body")
- } else {
- cell = tableView.dequeueReusableCell(withIdentifier: "file", for: indexPath) as? FileTextCell ?? FileTextCell()
- }
- case DC_MSG_WEBXDC:
- cell = tableView.dequeueReusableCell(withIdentifier: "webxdc", for: indexPath) as? WebxdcCell ?? WebxdcCell()
- case DC_MSG_AUDIO, DC_MSG_VOICE:
- if message.isUnsupportedMediaFile {
- cell = tableView.dequeueReusableCell(withIdentifier: "file", for: indexPath) as? FileTextCell ?? FileTextCell()
- } else {
- let audioMessageCell: AudioMessageCell = tableView.dequeueReusableCell(
- withIdentifier: "audio",
- for: indexPath) as? AudioMessageCell ?? AudioMessageCell()
- audioController.update(audioMessageCell, with: message.id)
- cell = audioMessageCell
- }
- default:
- cell = tableView.dequeueReusableCell(withIdentifier: "text", for: indexPath) as? TextMessageCell ?? TextMessageCell()
- }
- var showAvatar = isGroupChat && !message.isFromCurrentSender
- var showName = isGroupChat
- if message.overrideSenderName != nil {
- showAvatar = !message.isFromCurrentSender
- showName = true
- }
- cell.baseDelegate = self
- cell.showSelectionBackground(tableView.isEditing)
- cell.update(dcContext: dcContext,
- msg: message,
- messageStyle: configureMessageStyle(for: message, at: indexPath),
- showAvatar: showAvatar,
- showName: showName,
- searchText: searchController.searchBar.text,
- highlight: !searchMessageIds.isEmpty && message.id == searchMessageIds[searchResultIndex])
- return cell
- }
- public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
- if !decelerate {
- markSeenMessagesInVisibleArea()
- updateScrollDownButtonVisibility()
- }
- }
- public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
- markSeenMessagesInVisibleArea()
- updateScrollDownButtonVisibility()
- }
- override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
- markSeenMessagesInVisibleArea()
- updateScrollDownButtonVisibility()
- }
- private func updateScrollDownButtonVisibility() {
- messageInputBar.scrollDownButton.isHidden = messageIds.isEmpty || isLastRowVisible(checkTopCellPostion: true,
- checkBottomCellPosition: true,
- allowPartialVisibility: true)
- }
- private func configureContactRequestBar() {
- messageInputBar.separatorLine.backgroundColor = DcColors.colorDisabled
- messageInputBar.setMiddleContentView(contactRequestBar, animated: false)
- messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
- messageInputBar.setStackViewItems([], forStack: .top, animated: false)
- messageInputBar.onScrollDownButtonPressed = scrollToBottom
- }
- private func configureDraftArea(draft: DraftModel, animated: Bool = true) {
- if searchController.isActive {
- messageInputBar.setMiddleContentView(searchAccessoryBar, animated: false)
- messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.setStackViewItems([], forStack: .top, animated: false)
- messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
- return
- }
- draftArea.configure(draft: draft)
- if draft.isEditing {
- messageInputBar.setMiddleContentView(editingBar, animated: false)
- messageInputBar.setLeftStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
- messageInputBar.padding = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
- } else {
- messageInputBar.setMiddleContentView(messageInputBar.inputTextView, animated: false)
- messageInputBar.setLeftStackViewWidthConstant(to: 40, animated: false)
- messageInputBar.setRightStackViewWidthConstant(to: 40, animated: false)
- messageInputBar.padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 12)
- }
- messageInputBar.setStackViewItems([draftArea], forStack: .top, animated: animated)
- }
- override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
- let swipeAction = UISwipeActionsConfiguration(actions: [])
- return swipeAction
- }
- override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
- let message = dcContext.getMessage(id: messageIds[indexPath.row])
- if !dcChat.canSend || message.isInfo || message.type == DC_MSG_VIDEOCHAT_INVITATION {
- return nil
- }
- let action = UIContextualAction(style: .normal, title: nil,
- handler: { [weak self] (_, _, completionHandler) in
- self?.keepKeyboard = true
- self?.replyToMessage(at: indexPath)
- completionHandler(true)
- })
- if #available(iOS 13.0, *) {
- action.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?.sd_tintedImage(with: DcColors.defaultInverseColor)
- action.backgroundColor = DcColors.chatBackgroundColor.withAlphaComponent(0.25)
- } else {
- action.image = UIImage(named: "ic_reply_black")
- action.backgroundColor = .systemBlue
- }
- action.accessibilityElements = nil
- let configuration = UISwipeActionsConfiguration(actions: [action])
- return configuration
- }
- func replyToMessage(at indexPath: IndexPath) {
- let message = dcContext.getMessage(id: self.messageIds[indexPath.row])
- self.draft.setQuote(quotedMsg: message)
- self.configureDraftArea(draft: self.draft)
- focusInputTextView()
- }
- func replyPrivatelyToMessage(at indexPath: IndexPath) {
- let msgId = self.messageIds[indexPath.row]
- let message = dcContext.getMessage(id: msgId)
- let privateChatId = dcContext.createChatByContactId(contactId: message.fromContactId)
- let replyMsg: DcMsg = dcContext.newMessage(viewType: DC_MSG_TEXT)
- replyMsg.quoteMessage = message
- dcContext.setDraft(chatId: privateChatId, message: replyMsg)
- showChat(chatId: privateChatId)
- }
- func markSeenMessagesInVisibleArea() {
- if isVisibleToUser,
- let indexPaths = tableView.indexPathsForVisibleRows {
- let visibleMessagesIds = indexPaths.map { UInt32(messageIds[$0.row]) }
- if !visibleMessagesIds.isEmpty {
- DispatchQueue.global(qos: .background).async { [weak self] in
- self?.dcContext.markSeenMessages(messageIds: visibleMessagesIds)
- }
- }
- }
- }
-
- func markSeenMessage(id: Int) {
- if isVisibleToUser {
- DispatchQueue.global(qos: .background).async { [weak self] in
- self?.dcContext.markSeenMessages(messageIds: [UInt32(id)])
- }
- }
- }
- override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
- return tableView.cellForRow(at: indexPath) as? SelectableCell != nil
- }
- override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
- let tableViewCell = tableView.cellForRow(at: indexPath)
- if let selectableCell = tableViewCell as? SelectableCell,
- !(tableView.isEditing &&
- tableViewCell as? InfoMessageCell != nil &&
- messageIds[indexPath.row] <= DC_MSG_ID_LAST_SPECIAL) {
- selectableCell.showSelectionBackground(tableView.isEditing)
- return indexPath
- }
- return nil
- }
- override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
- if tableView.isEditing {
- handleEditingBar()
- updateTitle()
- }
- }
-
- override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- if tableView.isEditing {
- handleEditingBar()
- updateTitle()
- return
- }
- let messageId = messageIds[indexPath.row]
- let message = dcContext.getMessage(id: messageId)
- if message.isSetupMessage {
- didTapAsm(msg: message, orgText: "")
- } else if message.type == DC_MSG_FILE ||
- message.type == DC_MSG_AUDIO ||
- message.type == DC_MSG_VOICE {
- showMediaGalleryFor(message: message)
- } else if message.type == DC_MSG_VIDEOCHAT_INVITATION {
- if let url = NSURL(string: message.getVideoChatUrl()) {
- UIApplication.shared.open(url as URL)
- }
- } else if message.isInfo, message.infoType == DC_INFO_WEBXDC_INFO_MESSAGE, let parent = message.parent {
- scrollToMessage(msgId: parent.id)
- }
- _ = handleUIMenu()
- }
-
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- messageInputBar.inputTextView.layer.borderColor = DcColors.colorDisabled.cgColor
- if #available(iOS 12.0, *),
- UserDefaults.standard.string(forKey: Constants.Keys.backgroundImageName) == nil {
- backgroundContainer.image = UIImage(named: traitCollection.userInterfaceStyle == .light ? "background_light" : "background_dark")
- }
- }
- func configureMessageStyle(for message: DcMsg, at indexPath: IndexPath) -> UIRectCorner {
- var corners: UIRectCorner = []
- if message.isFromCurrentSender {
- corners.formUnion(.topLeft)
- corners.formUnion(.bottomLeft)
- corners.formUnion(.topRight)
- } else {
- corners.formUnion(.topRight)
- corners.formUnion(.bottomRight)
- corners.formUnion(.topLeft)
- }
- return corners
- }
- private func updateTitle() {
- if tableView.isEditing {
- navigationItem.titleView = nil
- let cnt = tableView.indexPathsForSelectedRows?.count ?? 0
- navigationItem.title = String.localized(stringID: "n_selected", count: cnt)
- self.navigationItem.setLeftBarButton(cancelButton, animated: true)
- } else {
- var subtitle = ""
- let chatContactIds = dcChat.getContactIds(dcContext)
- if dcChat.isMailinglist {
- subtitle = String.localized("mailing_list")
- } else if dcChat.isBroadcast {
- subtitle = String.localized(stringID: "n_recipients", count: chatContactIds.count)
- } else if dcChat.isGroup {
- subtitle = String.localized(stringID: "n_members", count: chatContactIds.count)
- } else if dcChat.isDeviceTalk {
- subtitle = String.localized("device_talk_subtitle")
- } else if dcChat.isSelfTalk {
- subtitle = String.localized("chat_self_talk_subtitle")
- } else if chatContactIds.count >= 1 {
- subtitle = dcContext.getContact(id: chatContactIds[0]).email
- }
- titleView.updateTitleView(title: dcChat.name, subtitle: subtitle, isVerified: dcChat.isProtected)
- navigationItem.titleView = titleView
- self.navigationItem.setLeftBarButton(nil, animated: true)
- }
- if let image = dcChat.profileImage {
- initialsBadge.setImage(image)
- } else {
- initialsBadge.setName(dcChat.name)
- initialsBadge.setColor(dcChat.color)
- }
- let recentlySeen = DcUtils.showRecentlySeen(context: dcContext, chat: dcChat)
- initialsBadge.setRecentlySeen(recentlySeen)
- var rightBarButtonItems = [badgeItem]
- if dcChat.isSendingLocations {
- rightBarButtonItems.append(locationStreamingItem)
- }
- if dcChat.isMuted {
- rightBarButtonItems.append(muteItem)
- }
- if dcContext.getChatEphemeralTimer(chatId: dcChat.id) > 0 {
- rightBarButtonItems.append(ephemeralMessageItem)
- }
- navigationItem.rightBarButtonItems = rightBarButtonItems
- }
- @objc
- private func refreshMessages() {
- self.messageIds = dcContext.getChatMsgs(chatId: chatId)
- let wasLastSectionScrolledToBottom = isLastRowScrolledToBottom()
- self.reloadData()
- if wasLastSectionScrolledToBottom {
- self.scrollToBottom(animated: true)
- }
- self.showEmptyStateView(self.messageIds.isEmpty)
- }
- private func reloadData() {
- let selectredRows = tableView.indexPathsForSelectedRows
- tableView.reloadData()
- // There's an iOS bug, filling up the console output but which can be ignored: https://developer.apple.com/forums/thread/668295
- // [Assert] Attempted to call -cellForRowAtIndexPath: on the table view while it was in the process of updating its visible cells, which is not allowed.
- selectredRows?.forEach({ (selectedRow) in
- tableView.selectRow(at: selectedRow, animated: false, scrollPosition: .none)
- })
- }
- private func loadMessages() {
- // update message ids
- var msgIds = dcContext.getChatMsgs(chatId: chatId)
- let freshMsgsCount = self.dcContext.getUnreadMessages(chatId: self.chatId)
- if freshMsgsCount > 0 && msgIds.count >= freshMsgsCount {
- let index = msgIds.count - freshMsgsCount
- msgIds.insert(Int(DC_MSG_ID_MARKER1), at: index)
- }
- self.messageIds = msgIds
- self.showEmptyStateView(self.messageIds.isEmpty)
- self.reloadData()
- }
- private func isLastRowScrolledToBottom() -> Bool {
- return isLastRowVisible(checkTopCellPostion: false, checkBottomCellPosition: true)
- }
- // verifies if the last message cell is visible
- // - ommitting the parameters results in a simple check if the last message cell is preloaded. In that case it is not guaranteed that the cell is actually visible to the user and not covered e.g. by the messageInputBar
- // - if set to true, checkTopCellPosition verifies if the top of the last message cell is visible to the user
- // - if set to true, checkBottomCellPosition verifies if the bottom of the last message cell is visible to the user
- // - if set to true, allowPartialVisiblity ensures that any part of the last message shown in the visible area results in a true return value.
- // Using this flag large messages exceeding actual screen space are handled gracefully. This flag is only taken into account if checkTopCellPostion and checkBottomCellPosition are both set to true
- private func isLastRowVisible(checkTopCellPostion: Bool = false, checkBottomCellPosition: Bool = false, allowPartialVisibility: Bool = false) -> Bool {
- guard !messageIds.isEmpty else { return false }
- let lastIndexPath = IndexPath(item: messageIds.count - 1, section: 0)
- if !(checkTopCellPostion || checkBottomCellPosition) {
- return tableView.indexPathsForVisibleRows?.contains(lastIndexPath) ?? false
- }
- guard let window = UIApplication.shared.keyWindow else {
- return tableView.indexPathsForVisibleRows?.contains(lastIndexPath) ?? false
- }
- let rectOfCellInTableView = tableView.rectForRow(at: lastIndexPath)
- // convert points to same coordination system
- let inputBarTopInWindow = window.bounds.maxY - (messageInputBar.intrinsicContentSize.height + messageInputBar.keyboardHeight)
- var cellTopInWindow = tableView.convert(CGPoint(x: 0, y: rectOfCellInTableView.minY), to: window)
- cellTopInWindow.y = floor(cellTopInWindow.y)
- var cellBottomInWindow = tableView.convert(CGPoint(x: 0, y: rectOfCellInTableView.maxY), to: window)
- cellBottomInWindow.y = floor(cellBottomInWindow.y)
- let tableViewTopInWindow = tableView.convert(CGPoint(x: 0, y: tableView.bounds.minY), to: window)
- // check if top and bottom of the message are within the visible area
- let isTopVisible = cellTopInWindow.y < inputBarTopInWindow && cellTopInWindow.y >= tableViewTopInWindow.y
- let isBottomVisible = cellBottomInWindow.y <= inputBarTopInWindow && cellBottomInWindow.y >= tableViewTopInWindow.y
- // check if the message is visible, but top and bottom of cell exceed visible area
- let messageExceedsScreen = cellTopInWindow.y < tableViewTopInWindow.y && cellBottomInWindow.y > inputBarTopInWindow
- if checkTopCellPostion && checkBottomCellPosition {
- return allowPartialVisibility ?
- isTopVisible || isBottomVisible || messageExceedsScreen :
- isTopVisible && isBottomVisible
- } else if checkTopCellPostion {
- return isTopVisible
- } else {
- // checkBottomCellPosition
- return isBottomVisible
- }
- }
-
- private func scrollToBottom() {
- scrollToBottom(animated: true)
- }
-
- private func scrollToBottom(animated: Bool, focusOnVoiceOver: Bool = false) {
- if !messageIds.isEmpty {
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- let numberOfRows = self.tableView.numberOfRows(inSection: 0)
- if numberOfRows > 0 {
- self.scrollToRow(at: IndexPath(row: numberOfRows - 1, section: 0),
- position: .bottom,
- animated: animated,
- focusWithVoiceOver: focusOnVoiceOver)
- }
- }
- }
- }
- private func scrollToLastUnseenMessage() {
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- if let markerMessageIndex = self.messageIds.firstIndex(of: Int(DC_MSG_ID_MARKER1)) {
- let indexPath = IndexPath(row: markerMessageIndex, section: 0)
- self.scrollToRow(at: indexPath, animated: false)
- } else {
- // scroll to bottom
- let numberOfRows = self.tableView.numberOfRows(inSection: 0)
- if numberOfRows > 0 {
- self.scrollToRow(at: IndexPath(row: numberOfRows - 1, section: 0), animated: false)
- }
- }
- }
- }
- private func scrollToMessage(msgId: Int, animated: Bool = true, scrollToText: Bool = false) {
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- guard let index = self.messageIds.firstIndex(of: msgId) else {
- return
- }
- let indexPath = IndexPath(row: index, section: 0)
- if scrollToText && !UIAccessibility.isVoiceOverRunning {
- self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
- let cell = self.tableView.cellForRow(at: indexPath)
- if let messageCell = cell as? BaseMessageCell {
- let textYPos = messageCell.getTextOffset(of: self.searchController.searchBar.text)
- let currentYPos = self.tableView.contentOffset.y
- let padding: CGFloat = 12
- self.tableView.setContentOffset(CGPoint(x: 0,
- y: textYPos +
- currentYPos -
- 2 * UIFont.preferredFont(for: .body, weight: .regular).lineHeight -
- padding),
- animated: false)
- return
- }
- }
- self.scrollToRow(at: indexPath, animated: false)
- }
- }
- private func scrollToRow(at indexPath: IndexPath, position: UITableView.ScrollPosition = .top, animated: Bool, focusWithVoiceOver: Bool = true) {
- if UIAccessibility.isVoiceOverRunning && focusWithVoiceOver {
- self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
- self.markSeenMessagesInVisibleArea()
- self.updateScrollDownButtonVisibility()
- self.forceVoiceOverFocussingCell(at: indexPath) { [weak self] in
- self?.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
- }
- } else {
- self.tableView.scrollToRow(at: indexPath, at: position, animated: animated)
- }
- }
- // VoiceOver tends to jump and read out the top visible cell within the tableView if we
- // don't force it to refocus the cell we're interested in. Posting multiple times a .layoutChanged
- // notification doesn't cause VoiceOver to readout the cell mutliple times.
- private func forceVoiceOverFocussingCell(at indexPath: IndexPath, postingFinished: (() -> Void)?) {
- DispatchQueue.global(qos: .userInteractive).async { [weak self] in
- guard let self = self else { return }
- for _ in 1...4 {
- DispatchQueue.main.async {
- UIAccessibility.post(notification: .layoutChanged, argument: self.tableView.cellForRow(at: indexPath))
- postingFinished?()
- }
- usleep(500_000)
- }
- }
- }
- private func showEmptyStateView(_ show: Bool) {
- if show {
- if dcChat.isGroup {
- if dcChat.isBroadcast {
- emptyStateView.text = String.localized("chat_new_broadcast_hint")
- } else if dcChat.isUnpromoted {
- emptyStateView.text = String.localized("chat_new_group_hint")
- } else {
- emptyStateView.text = String.localized("chat_no_messages")
- }
- } else if dcChat.isSelfTalk {
- emptyStateView.text = String.localized("saved_messages_explain")
- } else if dcChat.isDeviceTalk {
- emptyStateView.text = String.localized("device_talk_explain")
- } else {
- emptyStateView.text = String.localizedStringWithFormat(String.localized("chat_new_one_to_one_hint"), dcChat.name)
- }
- emptyStateView.isHidden = false
- } else {
- emptyStateView.isHidden = true
- }
- }
- @objc private func saveDraft() {
- draft.save(context: dcContext)
- }
- private func configureMessageInputBar() {
- messageInputBar.delegate = self
- messageInputBar.inputTextView.tintColor = DcColors.primary
- messageInputBar.inputTextView.placeholder = String.localized("chat_input_placeholder")
- messageInputBar.inputTextView.accessibilityLabel = String.localized("write_message_desktop")
- messageInputBar.separatorLine.backgroundColor = DcColors.colorDisabled
- messageInputBar.inputTextView.tintColor = DcColors.primary
- messageInputBar.inputTextView.textColor = DcColors.defaultTextColor
- messageInputBar.inputTextView.backgroundColor = DcColors.inputFieldColor
- messageInputBar.inputTextView.placeholderTextColor = DcColors.placeholderColor
- messageInputBar.inputTextView.textContainerInset = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 38)
- messageInputBar.inputTextView.placeholderLabelInsets = UIEdgeInsets(top: 8, left: 20, bottom: 8, right: 38)
- messageInputBar.inputTextView.layer.borderColor = DcColors.colorDisabled.cgColor
- messageInputBar.inputTextView.layer.borderWidth = 1.0
- messageInputBar.inputTextView.layer.cornerRadius = 13.0
- messageInputBar.inputTextView.layer.masksToBounds = true
- messageInputBar.inputTextView.scrollIndicatorInsets = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
- configureInputBarItems()
- messageInputBar.inputTextView.delegate = self
- messageInputBar.inputTextView.imagePasteDelegate = self
- messageInputBar.onScrollDownButtonPressed = scrollToBottom
- messageInputBar.inputTextView.setDropInteractionDelegate(delegate: self)
- }
- private func evaluateInputBar(draft: DraftModel) {
- messageInputBar.sendButton.isEnabled = draft.canSend()
- messageInputBar.sendButton.accessibilityTraits = draft.canSend() ? .button : .notEnabled
- }
- private func configureInputBarItems() {
- messageInputBar.setLeftStackViewWidthConstant(to: 40, animated: false)
- messageInputBar.setRightStackViewWidthConstant(to: 40, animated: false)
- let sendButtonImage = UIImage(named: "paper_plane")?.withRenderingMode(.alwaysTemplate)
- messageInputBar.sendButton.image = sendButtonImage
- messageInputBar.sendButton.accessibilityLabel = String.localized("menu_send")
- messageInputBar.sendButton.accessibilityTraits = .button
- messageInputBar.sendButton.title = nil
- messageInputBar.sendButton.tintColor = UIColor(white: 1, alpha: 1)
- messageInputBar.sendButton.layer.cornerRadius = 20
- messageInputBar.middleContentViewPadding = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10)
- // this adds a padding between textinputfield and send button
- messageInputBar.sendButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
- messageInputBar.sendButton.setSize(CGSize(width: 40, height: 40), animated: false)
- messageInputBar.padding = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 12)
- messageInputBar.shouldManageSendButtonEnabledState = false
- let leftItems = [
- InputBarButtonItem()
- .configure {
- $0.spacing = .fixed(0)
- let clipperIcon = #imageLiteral(resourceName: "ic_attach_file_36pt").withRenderingMode(.alwaysTemplate)
- $0.image = clipperIcon
- $0.tintColor = DcColors.primary
- $0.setSize(CGSize(width: 40, height: 40), animated: false)
- $0.accessibilityLabel = String.localized("menu_add_attachment")
- $0.accessibilityTraits = .button
- }.onSelected {
- $0.tintColor = UIColor.themeColor(light: .lightGray, dark: .darkGray)
- }.onDeselected {
- $0.tintColor = DcColors.primary
- }.onTouchUpInside { [weak self] _ in
- self?.clipperButtonPressed()
- }
- ]
- messageInputBar.setStackViewItems(leftItems, forStack: .left, animated: false)
- // This just adds some more flare
- messageInputBar.sendButton
- .onEnabled { item in
- UIView.animate(withDuration: 0.3, animations: {
- item.backgroundColor = DcColors.primary
- })}
- .onDisabled { item in
- UIView.animate(withDuration: 0.3, animations: {
- item.backgroundColor = DcColors.colorDisabled
- })}
- }
- @objc private func chatProfilePressed() {
- if tableView.isEditing {
- return
- }
- showChatDetail(chatId: chatId)
- }
- @objc private func clipperButtonPressed() {
- showClipperOptions()
- }
- private func showClipperOptions() {
- let alert = UIAlertController(title: nil, message: nil, preferredStyle: .safeActionSheet)
- let galleryAction = PhotoPickerAlertAction(title: String.localized("gallery"), style: .default, handler: galleryButtonPressed(_:))
- let cameraAction = PhotoPickerAlertAction(title: String.localized("camera"), style: .default, handler: cameraButtonPressed(_:))
- let documentAction = UIAlertAction(title: String.localized("files"), style: .default, handler: documentActionPressed(_:))
- let voiceMessageAction = UIAlertAction(title: String.localized("voice_message"), style: .default, handler: voiceMessageButtonPressed(_:))
- let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
- let locationStreamingAction = UIAlertAction(title: isLocationStreaming ? String.localized("stop_sharing_location") : String.localized("location"),
- style: isLocationStreaming ? .destructive : .default,
- handler: locationStreamingButtonPressed(_:))
- alert.addAction(cameraAction)
- alert.addAction(galleryAction)
- alert.addAction(documentAction)
- if dcContext.hasWebxdc(chatId: 0) {
- let webxdcAction = UIAlertAction(title: String.localized("webxdc_apps"), style: .default, handler: webxdcButtonPressed(_:))
- alert.addAction(webxdcAction)
- }
-
- alert.addAction(voiceMessageAction)
- if let config = dcContext.getConfig("webrtc_instance"), !config.isEmpty {
- let videoChatInvitation = UIAlertAction(title: String.localized("videochat"), style: .default, handler: videoChatButtonPressed(_:))
- alert.addAction(videoChatInvitation)
- }
- if UserDefaults.standard.bool(forKey: "location_streaming") {
- alert.addAction(locationStreamingAction)
- }
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
- self.present(alert, animated: true, completion: {
- // unfortunately, voiceMessageAction.accessibilityHint does not work,
- // but this hack does the trick
- if UIAccessibility.isVoiceOverRunning {
- if let view = voiceMessageAction.value(forKey: "__representer") as? UIView {
- view.accessibilityHint = String.localized("a11y_voice_message_hint_ios")
- }
- }
- })
- }
- private func confirmationAlert(title: String, actionTitle: String, actionStyle: UIAlertAction.Style = .default, actionHandler: @escaping ((UIAlertAction) -> Void), cancelHandler: ((UIAlertAction) -> Void)? = nil) {
- let alert = UIAlertController(title: title,
- message: nil,
- preferredStyle: .safeActionSheet)
- alert.addAction(UIAlertAction(title: actionTitle, style: actionStyle, handler: actionHandler))
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: cancelHandler ?? { _ in
- self.dismiss(animated: true, completion: nil)
- }))
- present(alert, animated: true, completion: nil)
- }
- private func showMoreMenu() {
- let alert = UIAlertController(title: nil, message: nil, preferredStyle: .safeActionSheet)
- alert.addAction(UIAlertAction(title: String.localized("resend"), style: .default, handler: onResendActionPressed(_:)))
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
- present(alert, animated: true, completion: nil)
- }
- private func onResendActionPressed(_ action: UIAlertAction) {
- if let rows = tableView.indexPathsForSelectedRows {
- let selectedMsgIds = rows.compactMap { messageIds[$0.row] }
- dcContext.resendMessages(msgIds: selectedMsgIds)
- setEditing(isEditing: false)
- }
- }
- private func askToDeleteChat() {
- let title = String.localized(stringID: "ask_delete_chat", count: 1)
- confirmationAlert(title: title, actionTitle: String.localized("delete"), actionStyle: .destructive,
- actionHandler: { [weak self] _ in
- guard let self = self else { return }
- // remove message observers early to avoid careless calls to dcContext methods
- self.removeObservers()
- self.dcContext.deleteChat(chatId: self.chatId)
- self.navigationController?.popViewController(animated: true)
- })
- }
-
- private func askToChatWith(email: String) {
- let contactId = self.dcContext.createContact(name: "", email: email)
- if dcContext.getChatIdByContactId(contactId: contactId) != 0 {
- self.dismiss(animated: true, completion: nil)
- let chatId = self.dcContext.createChatByContactId(contactId: contactId)
- self.showChat(chatId: chatId)
- } else {
- confirmationAlert(title: String.localizedStringWithFormat(String.localized("ask_start_chat_with"), email),
- actionTitle: String.localized("start_chat"),
- actionHandler: { _ in
- self.dismiss(animated: true, completion: nil)
- let chatId = self.dcContext.createChatByContactId(contactId: contactId)
- self.showChat(chatId: chatId)})
- }
- }
- private func askToDeleteMessage(id: Int) {
- self.askToDeleteMessages(ids: [id])
- }
- private func askToDeleteMessages(ids: [Int]) {
- let chat = dcContext.getChat(chatId: chatId)
- let title = chat.isDeviceTalk ?
- String.localized(stringID: "ask_delete_messages_simple", count: ids.count) :
- String.localized(stringID: "ask_delete_messages", count: ids.count)
- confirmationAlert(title: title, actionTitle: String.localized("delete"), actionStyle: .destructive,
- actionHandler: { _ in
- self.dcContext.deleteMessages(msgIds: ids)
- if self.tableView.isEditing {
- self.setEditing(isEditing: false)
- }
- })
- }
- private func askToForwardMessage() {
- let chat = dcContext.getChat(chatId: self.chatId)
- if chat.isSelfTalk {
- RelayHelper.shared.forward(to: self.chatId)
- refreshMessages()
- } else {
- confirmationAlert(title: String.localizedStringWithFormat(String.localized("ask_forward"), chat.name),
- actionTitle: String.localized("menu_forward"),
- actionHandler: { _ in
- RelayHelper.shared.forward(to: self.chatId)
- self.dismiss(animated: true, completion: nil)},
- cancelHandler: { _ in
- self.dismiss(animated: false, completion: nil)
- self.navigationController?.popViewController(animated: true)})
- }
- }
- // MARK: - coordinator
- private func showChatDetail(chatId: Int) {
- let chat = dcContext.getChat(chatId: chatId)
- if !chat.isGroup {
- if let contactId = chat.getContactIds(dcContext).first {
- let contactDetailController = ContactDetailViewController(dcContext: dcContext, contactId: contactId)
- navigationController?.pushViewController(contactDetailController, animated: true)
- }
- } else {
- let groupChatDetailViewController = GroupChatDetailViewController(chatId: chatId, dcContext: dcContext)
- navigationController?.pushViewController(groupChatDetailViewController, animated: true)
- }
- }
- func showChat(chatId: Int, messageId: Int? = nil, animated: Bool = true) {
- if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
- appDelegate.appCoordinator.showChat(chatId: chatId, msgId: messageId, animated: animated, clearViewControllerStack: true)
- }
- }
- private func showWebxdcSelector() {
- let msgIds = dcContext.getChatMedia(chatId: 0, messageType: DC_MSG_WEBXDC, messageType2: 0, messageType3: 0)
- let webxdcSelector = WebxdcSelector(context: dcContext, mediaMessageIds: msgIds.reversed())
- webxdcSelector.delegate = self
- let webxdcSelectorNavigationController = UINavigationController(rootViewController: webxdcSelector)
- if #available(iOS 15.0, *) {
- if let sheet = webxdcSelectorNavigationController.sheetPresentationController {
- sheet.detents = [.medium()]
- sheet.preferredCornerRadius = 20
- }
- }
- self.present(webxdcSelectorNavigationController, animated: true)
- }
- private func showDocumentLibrary() {
- mediaPicker?.showDocumentLibrary()
- }
- private func showVoiceMessageRecorder() {
- mediaPicker?.showVoiceRecorder()
- }
- private func showCameraViewController() {
- if AVCaptureDevice.authorizationStatus(for: .video) == .authorized {
- self.mediaPicker?.showCamera()
- } else {
- AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- if granted {
- self.mediaPicker?.showCamera()
- } else {
- self.showCameraPermissionAlert()
- }
- }
- })
- }
- }
-
- private func showCameraPermissionAlert() {
- DispatchQueue.main.async { [weak self] in
- let alert = UIAlertController(title: String.localized("perm_required_title"),
- message: String.localized("perm_ios_explain_access_to_camera_denied"),
- preferredStyle: .alert)
- if let appSettings = URL(string: UIApplication.openSettingsURLString) {
- alert.addAction(UIAlertAction(title: String.localized("open_settings"), style: .default, handler: { _ in
- UIApplication.shared.open(appSettings, options: [:], completionHandler: nil)}))
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .destructive, handler: nil))
- }
- self?.present(alert, animated: true, completion: nil)
- }
- }
- private func showPhotoVideoLibrary(delegate: MediaPickerDelegate) {
- mediaPicker?.showPhotoVideoLibrary()
- }
- private func showMediaGallery(currentIndex: Int, msgIds: [Int]) {
- let previewController = PreviewController(dcContext: dcContext, type: .multi(msgIds, currentIndex))
- navigationController?.pushViewController(previewController, animated: true)
- }
- private func webxdcButtonPressed(_ action: UIAlertAction) {
- showWebxdcSelector()
- }
- private func documentActionPressed(_ action: UIAlertAction) {
- showDocumentLibrary()
- }
- private func voiceMessageButtonPressed(_ action: UIAlertAction) {
- showVoiceMessageRecorder()
- }
- private func cameraButtonPressed(_ action: UIAlertAction) {
- showCameraViewController()
- }
- private func galleryButtonPressed(_ action: UIAlertAction) {
- showPhotoVideoLibrary(delegate: self)
- }
- private func locationStreamingButtonPressed(_ action: UIAlertAction) {
- let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
- if isLocationStreaming {
- locationStreamingFor(seconds: 0)
- } else {
- let alert = UIAlertController(title: String.localized("title_share_location"), message: nil, preferredStyle: .safeActionSheet)
- addDurationSelectionAction(to: alert, key: "share_location_for_5_minutes", duration: Time.fiveMinutes)
- addDurationSelectionAction(to: alert, key: "share_location_for_30_minutes", duration: Time.thirtyMinutes)
- addDurationSelectionAction(to: alert, key: "share_location_for_one_hour", duration: Time.oneHour)
- addDurationSelectionAction(to: alert, key: "share_location_for_two_hours", duration: Time.twoHours)
- addDurationSelectionAction(to: alert, key: "share_location_for_six_hours", duration: Time.sixHours)
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
- self.present(alert, animated: true, completion: nil)
- }
- }
- private func videoChatButtonPressed(_ action: UIAlertAction) {
- let chat = dcContext.getChat(chatId: chatId)
- let alert = UIAlertController(title: String.localizedStringWithFormat(String.localized("videochat_invite_user_to_videochat"), chat.name),
- message: String.localized("videochat_invite_user_hint"),
- preferredStyle: .alert)
- let cancel = UIAlertAction(title: String.localized("cancel"), style: .default, handler: nil)
- let ok = UIAlertAction(title: String.localized("ok"),
- style: .default,
- handler: { _ in
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
- guard let self = self else { return }
- let messageId = self.dcContext.sendVideoChatInvitation(chatId: self.chatId)
- let inviteMessage = self.dcContext.getMessage(id: messageId)
- if let url = NSURL(string: inviteMessage.getVideoChatUrl()) {
- DispatchQueue.main.async {
- UIApplication.shared.open(url as URL)
- }
- }
- }})
- alert.addAction(cancel)
- alert.addAction(ok)
- self.present(alert, animated: true, completion: nil)
- }
- private func addDurationSelectionAction(to alert: UIAlertController, key: String, duration: Int) {
- let action = UIAlertAction(title: String.localized(key), style: .default, handler: { _ in
- self.locationStreamingFor(seconds: duration)
- })
- alert.addAction(action)
- }
- private func locationStreamingFor(seconds: Int) {
- guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
- return
- }
- appDelegate.locationManager.shareLocation(chatId: self.chatId, duration: seconds)
- }
- func updateMessage(_ msg: DcMsg) {
- if messageIds.firstIndex(of: msg.id) != nil {
- reloadData()
- } else {
- // new outgoing message
- if msg.state != DC_STATE_OUT_DRAFT,
- msg.chatId == chatId {
- logger.debug(">>> updateMessage: outgoing message \(msg.id)")
- if let newMsgMarkerIndex = messageIds.firstIndex(of: Int(DC_MSG_ID_MARKER1)) {
- messageIds.remove(at: newMsgMarkerIndex)
- }
- insertMessage(msg)
- } else if msg.type == DC_MSG_WEBXDC,
- msg.chatId == chatId {
- // webxdc draft got updated
- draft.draftMsg = msg
- configureDraftArea(draft: draft, animated: false)
- } else {
- logger.debug(">>> updateMessage: unhandled message \(msg.id) - msg.chatId: \(msg.chatId) vs. chatId: \(chatId) - msg.state: \(msg.state)")
- }
- }
- }
- func insertMessage(_ message: DcMsg) {
- logger.debug(">>> insertMessage \(message.id)")
- markSeenMessage(id: message.id)
- let wasLastSectionScrolledToBottom = isLastRowScrolledToBottom()
- messageIds.append(message.id)
- emptyStateView.isHidden = true
- reloadData()
- if UIAccessibility.isVoiceOverRunning && !message.isFromCurrentSender {
- scrollToBottom(animated: false, focusOnVoiceOver: true)
- } else if wasLastSectionScrolledToBottom || message.isFromCurrentSender {
- scrollToBottom(animated: true)
- } else {
- updateScrollDownButtonVisibility()
- }
- }
- private func sendTextMessage(text: String, quoteMessage: DcMsg?) {
- DispatchQueue.global().async { [weak self] in
- guard let self = self else { return }
- let message = self.dcContext.newMessage(viewType: DC_MSG_TEXT)
- message.text = text
- if let quoteMessage = quoteMessage {
- message.quoteMessage = quoteMessage
- }
- self.dcContext.sendMessage(chatId: self.chatId, message: message)
- }
- }
- private func focusInputTextView() {
- self.messageInputBar.inputTextView.becomeFirstResponder()
- if UIAccessibility.isVoiceOverRunning {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { [weak self] in
- UIAccessibility.post(notification: .layoutChanged, argument: self?.messageInputBar.inputTextView)
- })
- }
- }
- private func stageDocument(url: NSURL) {
- keepKeyboard = true
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.draft.setAttachment(viewType: url.pathExtension == "xdc" ? DC_MSG_WEBXDC : DC_MSG_FILE, path: url.relativePath)
- self.configureDraftArea(draft: self.draft)
- self.focusInputTextView()
- FileHelper.deleteFile(atPath: url.relativePath)
- }
- }
- private func stageVideo(url: NSURL) {
- keepKeyboard = true
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.draft.setAttachment(viewType: DC_MSG_VIDEO, path: url.relativePath)
- self.configureDraftArea(draft: self.draft)
- self.focusInputTextView()
- FileHelper.deleteFile(atPath: url.relativePath)
- }
- }
- private func stageImage(url: NSURL) {
- keepKeyboard = true
- DispatchQueue.global().async { [weak self] in
- if let image = ImageFormat.loadImageFrom(url: url as URL) {
- self?.stageImage(image)
- }
- }
- }
- private func stageImage(_ image: UIImage) {
- DispatchQueue.global().async { [weak self] in
- guard let self = self else { return }
- if let pathInCachesDir = ImageFormat.saveImage(image: image, directory: .cachesDirectory) {
- DispatchQueue.main.async {
- if pathInCachesDir.suffix(4).contains(".gif") {
- self.draft.setAttachment(viewType: DC_MSG_GIF, path: pathInCachesDir)
- } else {
- self.draft.setAttachment(viewType: DC_MSG_IMAGE, path: pathInCachesDir)
- }
- self.configureDraftArea(draft: self.draft)
- self.focusInputTextView()
- FileHelper.deleteFile(atPath: pathInCachesDir)
- }
- }
- }
- }
- private func sendImage(_ image: UIImage, message: String? = nil) {
- DispatchQueue.global().async { [weak self] in
- guard let self = self else { return }
- if let path = ImageFormat.saveImage(image: image, directory: .cachesDirectory) {
- self.sendAttachmentMessage(viewType: DC_MSG_IMAGE, filePath: path, message: message)
- FileHelper.deleteFile(atPath: path)
- }
- }
- }
- private func sendSticker(_ image: UIImage) {
- DispatchQueue.global().async { [weak self] in
- guard let self = self else { return }
- if let path = ImageFormat.saveImage(image: image, directory: .cachesDirectory) {
- self.sendAttachmentMessage(viewType: DC_MSG_STICKER, filePath: path, message: nil)
- FileHelper.deleteFile(atPath: path)
- }
- }
- }
- private func sendAttachmentMessage(viewType: Int32, filePath: String, message: String? = nil, quoteMessage: DcMsg? = nil) {
- let msg = draft.draftMsg ?? dcContext.newMessage(viewType: viewType)
- msg.setFile(filepath: filePath)
- msg.text = (message ?? "").isEmpty ? nil : message
- if quoteMessage != nil {
- msg.quoteMessage = quoteMessage
- }
- dcContext.sendMessage(chatId: self.chatId, message: msg)
- }
- private func sendVoiceMessage(url: NSURL) {
- DispatchQueue.global().async { [weak self] in
- guard let self = self else { return }
- let msg = self.dcContext.newMessage(viewType: DC_MSG_VOICE)
- if let quoteMessage = self.draft.quoteMessage {
- msg.quoteMessage = quoteMessage
- }
- msg.setFile(filepath: url.relativePath, mimeType: "audio/m4a")
- self.dcContext.sendMessage(chatId: self.chatId, message: msg)
- DispatchQueue.main.async {
- self.draft.setQuote(quotedMsg: nil)
- self.draftArea.quotePreview.cancel()
- }
- }
- }
- // MARK: - Context menu
- private func prepareContextMenu(isHidden: Bool) {
- if #available(iOS 13.0, *) {
- return
- }
- if isHidden {
- UIMenuController.shared.menuItems = nil
- } else {
- UIMenuController.shared.menuItems = contextMenu.menuItems
- }
- UIMenuController.shared.update()
- }
- override func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool {
- let messageId = messageIds[indexPath.row]
- let isHidden = messageId == DC_MSG_ID_MARKER1 || messageId == DC_MSG_ID_DAYMARKER
- prepareContextMenu(isHidden: isHidden)
- return !isHidden
- }
- @objc(tableView:canHandleDropSession:)
- func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
- return self.dropInteraction.dropInteraction(canHandle: session)
- }
- @objc
- func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
- return UITableViewDropProposal(operation: .copy)
- }
- @objc(tableView:performDropWithCoordinator:)
- func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
- return self.dropInteraction.dropInteraction(performDrop: coordinator.session)
- }
- override func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
- return !tableView.isEditing && contextMenu.canPerformAction(action: action)
- }
- override func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) {
- // handle standard actions here, but custom actions never trigger this. it still needs to be present for the menu to display, though.
- contextMenu.performAction(action: action, indexPath: indexPath)
- }
- @available(iOS 13.0, *)
- override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
- return makeTargetedPreview(for: configuration)
- }
- @available(iOS 13.0, *)
- override func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
- return makeTargetedPreview(for: configuration)
- }
- @available(iOS 13.0, *)
- private func makeTargetedPreview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
- guard let messageId = configuration.identifier as? NSString else { return nil }
- guard let index = messageIds.firstIndex(of: messageId.integerValue) else { return nil }
- let indexPath = IndexPath(row: index, section: 0)
- guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
- // clear background, so that background image is still visible
- let parameters = UIPreviewParameters()
- parameters.backgroundColor = .clear
- return UITargetedPreview(view: cell, parameters: parameters)
- }
- // context menu for iOS 13+
- @available(iOS 13, *)
- override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
- let messageId = messageIds[indexPath.row]
- if tableView.isEditing || messageId == DC_MSG_ID_MARKER1 || messageId == DC_MSG_ID_DAYMARKER {
- return nil
- }
- return UIContextMenuConfiguration(
- identifier: NSString(string: "\(messageId)"),
- previewProvider: nil,
- actionProvider: { [weak self] _ in
- guard let self = self else {
- return nil
- }
- if self.dcContext.getMessage(id: messageId).isInfo {
- return self.contextMenu.actionProvider(indexPath: indexPath,
- filters: [ { $0.action != self.replyItem.action && $0.action != self.replyPrivatelyItem.action } ])
- } else if self.isGroupChat && !self.dcContext.getMessage(id: messageId).isFromCurrentSender {
- return self.contextMenu.actionProvider(indexPath: indexPath)
- } else {
- return self.contextMenu.actionProvider(indexPath: indexPath,
- filters: [ { $0.action != self.replyPrivatelyItem.action } ])
- }
- }
- )
- }
- func showWebxdcViewFor(message: DcMsg) {
- let webxdcViewController = WebxdcViewController(dcContext: dcContext, messageId: message.id)
- navigationController?.pushViewController(webxdcViewController, animated: true)
- }
- func showMediaGalleryFor(indexPath: IndexPath) {
- let messageId = messageIds[indexPath.row]
- let message = dcContext.getMessage(id: messageId)
- if message.type != DC_MSG_STICKER {
- showMediaGalleryFor(message: message)
- }
- }
- func showMediaGalleryFor(message: DcMsg) {
- let msgIds = dcContext.getChatMedia(chatId: chatId, messageType: Int32(message.type), messageType2: 0, messageType3: 0)
- let index = msgIds.firstIndex(of: message.id) ?? 0
- showMediaGallery(currentIndex: index, msgIds: msgIds)
- }
- private func didTapAsm(msg: DcMsg, orgText: String) {
- let inputDlg = UIAlertController(
- title: String.localized("autocrypt_continue_transfer_title"),
- message: String.localized("autocrypt_continue_transfer_please_enter_code"),
- preferredStyle: .alert)
- inputDlg.addTextField(configurationHandler: { (textField) in
- textField.placeholder = msg.setupCodeBegin + ".."
- textField.text = orgText
- textField.keyboardType = UIKeyboardType.numbersAndPunctuation // allows entering spaces; decimalPad would require a mask to keep things readable
- })
- inputDlg.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
- let okAction = UIAlertAction(title: String.localized("ok"), style: .default, handler: { _ in
- let textField = inputDlg.textFields![0]
- let modText = textField.text ?? ""
- let success = self.dcContext.continueKeyTransfer(msgId: msg.id, setupCode: modText)
- let alert = UIAlertController(
- title: String.localized("autocrypt_continue_transfer_title"),
- message: String.localized(success ? "autocrypt_continue_transfer_succeeded" : "autocrypt_bad_setup_code"),
- preferredStyle: .alert)
- if success {
- alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
- } else {
- alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
- let retryAction = UIAlertAction(title: String.localized("autocrypt_continue_transfer_retry"), style: .default, handler: { _ in
- self.didTapAsm(msg: msg, orgText: modText)
- })
- alert.addAction(retryAction)
- alert.preferredAction = retryAction
- }
- self.navigationController?.present(alert, animated: true, completion: nil)
- })
- inputDlg.addAction(okAction)
- inputDlg.preferredAction = okAction // without setting preferredAction, cancel become shown *bold* as the preferred action
- navigationController?.present(inputDlg, animated: true, completion: nil)
- }
- func handleUIMenu() -> Bool {
- if UIMenuController.shared.isMenuVisible {
- UIMenuController.shared.setMenuVisible(false, animated: true)
- return true
- }
- return false
- }
- func handleSelection(indexPath: IndexPath) -> Bool {
- if tableView.isEditing {
- if tableView.indexPathsForSelectedRows?.contains(indexPath) ?? false {
- tableView.deselectRow(at: indexPath, animated: false)
- } else if let cell = tableView.cellForRow(at: indexPath) as? SelectableCell {
- cell.showSelectionBackground(true)
- tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
- }
- handleEditingBar()
- updateTitle()
- return true
- }
- return false
- }
- func handleEditingBar() {
- if let indexPaths = tableView.indexPathsForSelectedRows,
- !indexPaths.isEmpty {
- editingBar.isEnabled = true
- } else {
- editingBar.isEnabled = false
- }
- evaluateMoreButton()
- }
- func evaluateMoreButton() {
- if let rows = tableView.indexPathsForSelectedRows {
- let ids = rows.compactMap { messageIds[$0.row] }
- for msgId in ids {
- if !dcContext.getMessage(id: msgId).isFromCurrentSender {
- editingBar.moreButton.isEnabled = false
- return
- }
- }
- editingBar.moreButton.isEnabled = true
- }
- }
- func setEditing(isEditing: Bool, selectedAtIndexPath: IndexPath? = nil) {
- self.tableView.setEditing(isEditing, animated: true)
- self.draft.isEditing = isEditing
- self.configureDraftArea(draft: self.draft)
- if let indexPath = selectedAtIndexPath {
- _ = handleSelection(indexPath: indexPath)
- }
- self.updateTitle()
- }
- private func setDefaultBackgroundImage(view: UIImageView) {
- if #available(iOS 12.0, *) {
- view.image = UIImage(named: traitCollection.userInterfaceStyle == .light ? "background_light" : "background_dark")
- } else {
- view.image = UIImage(named: "background_light")
- }
- }
- private func copyToClipboard(ids: [Int]) {
- var stringsToCopy = ""
- if ids.count > 1 {
- let sortedIds = ids.sorted()
- var lastSenderId: Int = -1
- for id in sortedIds {
- let msg = self.dcContext.getMessage(id: id)
- var textToCopy: String?
- if msg.type == DC_MSG_TEXT || msg.type == DC_MSG_VIDEOCHAT_INVITATION, let msgText = msg.text {
- textToCopy = msgText
- } else if let msgSummary = msg.summary(chars: 10000000) {
- textToCopy = msgSummary
- }
- if let textToCopy = textToCopy {
- if lastSenderId != msg.fromContactId {
- let lastSender = msg.getSenderName(dcContext.getContact(id: msg.fromContactId))
- stringsToCopy.append("\(lastSender):\n")
- lastSenderId = msg.fromContactId
- }
- stringsToCopy.append("\(textToCopy)\n\n")
- }
- }
- if stringsToCopy.hasSuffix("\n\n") {
- stringsToCopy.removeLast(2)
- }
- } else {
- let msg = self.dcContext.getMessage(id: ids[0])
- if msg.type == DC_MSG_TEXT || msg.type == DC_MSG_VIDEOCHAT_INVITATION, let msgText = msg.text {
- stringsToCopy.append("\(msgText)")
- } else if let msgSummary = msg.summary(chars: 10000000) {
- stringsToCopy.append("\(msgSummary)")
- }
- }
- UIPasteboard.general.string = stringsToCopy
- }
- }
- // MARK: - BaseMessageCellDelegate
- extension ChatViewController: BaseMessageCellDelegate {
- @objc func actionButtonTapped(indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- let msg = dcContext.getMessage(id: messageIds[indexPath.row])
- if msg.downloadState != DC_DOWNLOAD_DONE {
- dcContext.downloadFullMessage(id: msg.id)
- } else if msg.type == DC_MSG_WEBXDC {
- showWebxdcViewFor(message: msg)
- } else {
- let fullMessageViewController = FullMessageViewController(dcContext: dcContext, messageId: msg.id, isContactRequest: dcChat.isContactRequest)
- navigationController?.pushViewController(fullMessageViewController, animated: true)
- }
- }
- @objc func quoteTapped(indexPath: IndexPath) {
- if handleSelection(indexPath: indexPath) { return }
- _ = handleUIMenu()
- let msg = dcContext.getMessage(id: messageIds[indexPath.row])
- if let quoteMsg = msg.quoteMessage {
- if self.chatId == quoteMsg.chatId {
- scrollToMessage(msgId: quoteMsg.id)
- } else {
- showChat(chatId: quoteMsg.chatId, messageId: quoteMsg.id, animated: false)
- }
- }
- }
- @objc func textTapped(indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- let message = dcContext.getMessage(id: messageIds[indexPath.row])
- if message.isSetupMessage {
- didTapAsm(msg: message, orgText: "")
- }
- }
- @objc func phoneNumberTapped(number: String, indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- let sanitizedNumber = number.filter("0123456789".contains)
- if let phoneURL = URL(string: "tel://\(sanitizedNumber)") {
- UIApplication.shared.open(phoneURL, options: [:], completionHandler: nil)
- }
- logger.debug("phone number tapped \(sanitizedNumber)")
- }
- @objc func commandTapped(command: String, indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- if let text = messageInputBar.inputTextView.text, !text.isEmpty {
- return
- }
- messageInputBar.inputTextView.text = command + " "
- }
- @objc func urlTapped(url: URL, indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- if Utils.isEmail(url: url) {
- logger.debug("tapped on contact")
- let email = Utils.getEmailFrom(url)
- self.askToChatWith(email: email)
- } else {
- UIApplication.shared.open(url)
- }
- }
- @objc func imageTapped(indexPath: IndexPath) {
- if handleUIMenu() || handleSelection(indexPath: indexPath) {
- return
- }
- let message = dcContext.getMessage(id: messageIds[indexPath.row])
- if message.type == DC_MSG_WEBXDC {
- showWebxdcViewFor(message: message)
- } else {
- showMediaGalleryFor(indexPath: indexPath)
- }
- }
- @objc func avatarTapped(indexPath: IndexPath) {
- let message = dcContext.getMessage(id: messageIds[indexPath.row])
- let contactDetailController = ContactDetailViewController(dcContext: dcContext, contactId: message.fromContactId)
- navigationController?.pushViewController(contactDetailController, animated: true)
- }
- }
- // MARK: - MediaPickerDelegate
- extension ChatViewController: MediaPickerDelegate {
- func onVideoSelected(url: NSURL) {
- stageVideo(url: url)
- }
- func onImageSelected(url: NSURL) {
- stageImage(url: url)
- }
- func onImageSelected(image: UIImage) {
- stageImage(image)
- }
- func onVoiceMessageRecorded(url: NSURL) {
- sendVoiceMessage(url: url)
- }
- func onVoiceMessageRecorderClosed() {
- if UIAccessibility.isVoiceOverRunning {
- _ = try? AVAudioSession.sharedInstance().setCategory(.playback)
- UIAccessibility.post(notification: .announcement, argument: nil)
- }
- }
- func onDocumentSelected(url: NSURL) {
- stageDocument(url: url)
- }
- }
- // MARK: - MessageInputBarDelegate
- extension ChatViewController: InputBarAccessoryViewDelegate {
- func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
- keepKeyboard = true
- let trimmedText = text.replacingOccurrences(of: "\u{FFFC}", with: "", options: .literal, range: nil)
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let filePath = draft.attachment, let viewType = draft.viewType {
- switch viewType {
- case DC_MSG_GIF, DC_MSG_IMAGE, DC_MSG_FILE, DC_MSG_VIDEO, DC_MSG_WEBXDC:
- self.sendAttachmentMessage(viewType: viewType, filePath: filePath, message: trimmedText, quoteMessage: draft.quoteMessage)
- default:
- logger.warning("Unsupported viewType for drafted messages.")
- }
- } else if inputBar.inputTextView.images.isEmpty {
- self.sendTextMessage(text: trimmedText, quoteMessage: draft.quoteMessage)
- } else {
- // only 1 attachment allowed for now, thus it takes the first one
- self.sendImage(inputBar.inputTextView.images[0], message: trimmedText)
- }
- inputBar.inputTextView.text = String()
- inputBar.inputTextView.attributedText = nil
- draft.clear()
- draftArea.cancel()
- }
- func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String) {
- draft.text = text
- evaluateInputBar(draft: draft)
- }
- }
- // MARK: - DraftPreviewDelegate
- extension ChatViewController: DraftPreviewDelegate {
- func onCancelQuote() {
- keepKeyboard = true
- draft.setQuote(quotedMsg: nil)
- configureDraftArea(draft: draft)
- focusInputTextView()
- }
- func onCancelAttachment() {
- keepKeyboard = true
- draft.clearAttachment()
- configureDraftArea(draft: draft)
- evaluateInputBar(draft: draft)
- focusInputTextView()
- }
- func onAttachmentAdded() {
- evaluateInputBar(draft: draft)
- }
- func onAttachmentTapped() {
- if let attachmentPath = draft.attachment {
- let attachmentURL = URL(fileURLWithPath: attachmentPath, isDirectory: false)
- if draft.viewType == DC_MSG_WEBXDC, let draftMessage = draft.draftMsg {
- showWebxdcViewFor(message: draftMessage)
- } else {
- let previewController = PreviewController(dcContext: dcContext, type: .single(attachmentURL))
- if #available(iOS 13.0, *), draft.viewType == DC_MSG_IMAGE || draft.viewType == DC_MSG_VIDEO {
- previewController.setEditing(true, animated: true)
- previewController.delegate = self
- }
- navigationController?.pushViewController(previewController, animated: true)
- }
- }
- }
- }
- // MARK: - ChatEditingDelegate
- extension ChatViewController: ChatEditingDelegate {
- func onDeletePressed() {
- if let rows = tableView.indexPathsForSelectedRows {
- let messageIdsToDelete = rows.compactMap { messageIds[$0.row] }
- askToDeleteMessages(ids: messageIdsToDelete)
- }
- }
- func onMorePressed() {
- showMoreMenu()
- }
- func onForwardPressed() {
- if let rows = tableView.indexPathsForSelectedRows {
- let messageIdsToForward = rows.compactMap { messageIds[$0.row] }
- RelayHelper.shared.setForwardMessages(messageIds: messageIdsToForward)
- self.navigationController?.popViewController(animated: true)
- }
- }
- @objc func onCancelPressed() {
- setEditing(isEditing: false)
- }
- func onCopyPressed() {
- if let rows = tableView.indexPathsForSelectedRows {
- let ids = rows.compactMap { messageIds[$0.row] }
- copyToClipboard(ids: ids)
- setEditing(isEditing: false)
- }
- }
- }
- // MARK: - ChatSearchDelegate
- extension ChatViewController: ChatSearchDelegate {
- func onSearchPreviousPressed() {
- logger.debug("onSearch Previous Pressed")
- if searchResultIndex == 0 && !searchMessageIds.isEmpty {
- searchResultIndex = searchMessageIds.count - 1
- } else {
- searchResultIndex -= 1
- }
- scrollToMessage(msgId: searchMessageIds[searchResultIndex], animated: true, scrollToText: true)
- searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
- self.reloadData()
- }
- func onSearchNextPressed() {
- logger.debug("onSearch Next Pressed")
- if searchResultIndex == searchMessageIds.count - 1 {
- searchResultIndex = 0
- } else {
- searchResultIndex += 1
- }
- scrollToMessage(msgId: searchMessageIds[searchResultIndex], animated: true, scrollToText: true)
- searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: searchResultIndex + 1)
- self.reloadData()
- }
- }
- // MARK: UISearchResultUpdating
- extension ChatViewController: UISearchResultsUpdating {
- func updateSearchResults(for searchController: UISearchController) {
- logger.debug("searchbar: \(String(describing: searchController.searchBar.text))")
- debounceTimer?.invalidate()
- debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { _ in
- let searchText = searchController.searchBar.text ?? ""
- DispatchQueue.global(qos: .userInteractive).async { [weak self] in
- guard let self = self else { return }
- let resultIds = self.dcContext.searchMessages(chatId: self.chatId, searchText: searchText)
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.searchMessageIds = resultIds
- self.searchResultIndex = self.searchMessageIds.isEmpty ? 0 : self.searchMessageIds.count - 1
- self.searchAccessoryBar.isEnabled = !resultIds.isEmpty
- self.searchAccessoryBar.updateSearchResult(sum: self.searchMessageIds.count, position: self.searchResultIndex + 1)
- if let lastId = resultIds.last {
- self.scrollToMessage(msgId: lastId, animated: true, scrollToText: true)
- }
- self.reloadData()
- }
- }
- }
- }
- }
- // MARK: - UISearchBarDelegate
- extension ChatViewController: UISearchBarDelegate {
- func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
- configureDraftArea(draft: draft)
- return true
- }
- func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
- configureDraftArea(draft: draft)
- tableView.becomeFirstResponder()
- }
- func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
- searchController.isActive = false
- configureDraftArea(draft: draft)
- tableView.becomeFirstResponder()
- navigationItem.searchController = nil
- reloadData()
- }
- }
- // MARK: - UISearchControllerDelegate
- extension ChatViewController: UISearchControllerDelegate {
- func didPresentSearchController(_ searchController: UISearchController) {
- DispatchQueue.main.async { [weak self] in
- self?.searchController.searchBar.becomeFirstResponder()
- }
- }
- }
- // MARK: - ChatContactRequestBar
- extension ChatViewController: ChatContactRequestDelegate {
- func onAcceptRequest() {
- dcContext.acceptChat(chatId: chatId)
- let chat = dcContext.getChat(chatId: chatId)
- if chat.isMailinglist {
- messageInputBar.isHidden = true
- } else {
- configureUIForWriting()
- }
- }
- func onBlockRequest() {
- dcContext.blockChat(chatId: chatId)
- self.navigationController?.popViewController(animated: true)
- }
-
- func onDeleteRequest() {
- self.askToDeleteChat()
- }
- }
- // MARK: - QLPreviewControllerDelegate
- extension ChatViewController: QLPreviewControllerDelegate {
- @available(iOS 13.0, *)
- func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
- return .updateContents
- }
- func previewController(_ controller: QLPreviewController, didUpdateContentsOf previewItem: QLPreviewItem) {
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.draftArea.reload(draft: self.draft)
- }
- }
- }
- // MARK: - AudioControllerDelegate
- extension ChatViewController: AudioControllerDelegate {
- func onAudioPlayFailed() {
- let alert = UIAlertController(title: String.localized("error"),
- message: String.localized("cannot_play_audio_file"),
- preferredStyle: .safeActionSheet)
- alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
- self.present(alert, animated: true, completion: nil)
- }
- }
- // MARK: - UITextViewDelegate
- extension ChatViewController: UITextViewDelegate {
- func textViewShouldEndEditing(_ textView: UITextView) -> Bool {
- if keepKeyboard {
- DispatchQueue.main.async { [weak self] in
- self?.messageInputBar.inputTextView.becomeFirstResponder()
- }
- keepKeyboard = false
- return false
- }
- return true
- }
- }
- // MARK: - ChatInputTextViewPasteDelegate
- extension ChatViewController: ChatInputTextViewPasteDelegate {
- func onImagePasted(image: UIImage) {
- sendSticker(image)
- }
- }
- extension ChatViewController: WebxdcSelectorDelegate {
- func onWebxdcFromFilesSelected(url: NSURL) {
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- self.tableView.becomeFirstResponder()
- self.onDocumentSelected(url: url)
- }
- }
- func onWebxdcSelected(msgId: Int) {
- keepKeyboard = true
- DispatchQueue.main.async { [weak self] in
- guard let self = self else { return }
- let message = self.dcContext.getMessage(id: msgId)
- if let filename = message.fileURL {
- let nsdata = NSData(contentsOf: filename)
- guard let data = nsdata as? Data else { return }
- let url = FileHelper.saveData(data: data, suffix: "xdc", directory: .cachesDirectory)
- self.draft.setAttachment(viewType: DC_MSG_WEBXDC, path: url)
- self.configureDraftArea(draft: self.draft)
- self.focusInputTextView()
- }
- }
- }
- }
- extension ChatViewController: ChatDropInteractionDelegate {
- func onImageDragAndDropped(image: UIImage) {
- stageImage(image)
- }
- func onVideoDragAndDropped(url: NSURL) {
- stageVideo(url: url)
- }
- func onFileDragAndDropped(url: NSURL) {
- stageDocument(url: url)
- }
- func onTextDragAndDropped(text: String) {
- if messageInputBar.inputTextView.text.isEmpty {
- messageInputBar.inputTextView.text = text
- } else {
- var updatedText = messageInputBar.inputTextView.text
- updatedText?.append(" \(text) ")
- messageInputBar.inputTextView.text = updatedText
- }
- }
- }
|