123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483 |
- //
- // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- // SPDX-License-Identifier: GPL-3.0-or-later
- //
- import Foundation
- import NextcloudKit
- import PhotosUI
- import UIKit
- import Realm
- import ContactsUI
- import QuickLook
- @objcMembers public class BaseChatViewController: InputbarViewController,
- UITextFieldDelegate,
- UIImagePickerControllerDelegate,
- PHPickerViewControllerDelegate,
- UINavigationControllerDelegate,
- PollCreationViewControllerDelegate,
- ShareLocationViewControllerDelegate,
- CNContactPickerDelegate,
- UIDocumentPickerDelegate,
- VLCKitVideoViewControllerDelegate,
- ShareViewControllerDelegate,
- QLPreviewControllerDelegate,
- QLPreviewControllerDataSource,
- NCChatFileControllerDelegate,
- ShareConfirmationViewControllerDelegate,
- AVAudioRecorderDelegate,
- AVAudioPlayerDelegate,
- SystemMessageTableViewCellDelegate,
- BaseChatTableViewCellDelegate,
- UITableViewDataSourcePrefetching {
- // MARK: - Internal var
- internal var messages: [Date: [NCChatMessage]] = [:]
- internal var dateSections: [Date] = []
- internal var isVisible = false
- internal var isTyping = false
- internal var firstUnreadMessage: NCChatMessage?
- internal var dismissNotificationsOnViewWillDisappear = true
- internal var replyMessageView: ReplyMessageView?
- internal var voiceMessagesPlayer: AVAudioPlayer?
- internal var interactingMessage: NCChatMessage?
- internal var lastMessageBeforeInteraction: IndexPath?
- internal var contextMenuActionBlock: (() -> Void)?
- internal var editingMessage: NCChatMessage?
- internal lazy var emojiTextField: EmojiTextField = {
- let emojiTextField = EmojiTextField()
- emojiTextField.delegate = self
- self.view.addSubview(emojiTextField)
- return emojiTextField
- }()
- internal lazy var datePickerTextField: DatePickerTextField = {
- let datePicker = DatePickerTextField()
- datePicker.delegate = self
- self.view.addSubview(datePicker)
- return datePicker
- }()
- internal lazy var chatBackgroundView: PlaceholderView = {
- let chatBackgroundView = PlaceholderView()
- chatBackgroundView.placeholderView.isHidden = true
- chatBackgroundView.loadingView.startAnimating()
- chatBackgroundView.placeholderTextView.text = NSLocalizedString("No messages yet, start the conversation!", comment: "")
- chatBackgroundView.setImage(UIImage(named: "chat-placeholder"))
- return chatBackgroundView
- }()
- // MARK: - Private var
- private var sendButtonTagMessage = 99
- private var sendButtonTagVoice = 98
- private var actionTypeTranscribeVoiceMessage = "transcribe-voice-message"
- private var imagePicker: UIImagePickerController?
- private var stopTypingTimer: Timer?
- private var typingTimer: Timer?
- private var voiceMessageLongPressGesture: UILongPressGestureRecognizer?
- private var recorder: AVAudioRecorder?
- private var voiceMessageRecordingView: VoiceMessageRecordingView?
- private var longPressStartingPoint: CGPoint?
- private var cancelHintLabelInitialPositionX: CGFloat?
- private var recordCancelled: Bool = false
- private var animationDispatchGroup = DispatchGroup()
- private var animationDispatchQueue = DispatchQueue(label: "\(groupIdentifier).animationQueue")
- private var loadingHistoryView: UIActivityIndicatorView?
- private var isPreviewControllerShown: Bool = false
- private var previewControllerFilePath: String?
- private var playerProgressTimer: Timer?
- private var playerAudioFileStatus: NCChatFileStatus?
- private var photoPicker: PHPickerViewController?
- private var contextMenuAccessoryView: UIView?
- private var contextMenuMessageView: UIView?
- private lazy var inputbarBorderView: UIView = {
- let inputbarBorderView = UIView()
- inputbarBorderView.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]
- inputbarBorderView.frame = .init(x: 0, y: 0, width: self.textInputbar.frame.size.width, height: 1)
- inputbarBorderView.isHidden = true
- inputbarBorderView.backgroundColor = .systemGray6
- self.textInputbar.addSubview(inputbarBorderView)
- return inputbarBorderView
- }()
- private lazy var unreadMessageButton: UIButton = {
- let unreadMessageButton = UIButton(frame: .init(x: 0, y: 0, width: 126, height: 24))
- unreadMessageButton.backgroundColor = NCAppBranding.themeColor()
- unreadMessageButton.setTitleColor(NCAppBranding.themeTextColor(), for: .normal)
- unreadMessageButton.titleLabel?.font = UIFont.systemFont(ofSize: 12)
- unreadMessageButton.layer.cornerRadius = 12
- unreadMessageButton.clipsToBounds = true
- unreadMessageButton.isHidden = true
- unreadMessageButton.translatesAutoresizingMaskIntoConstraints = false
- unreadMessageButton.contentEdgeInsets = .init(top: 0, left: 10, bottom: 0, right: 10)
- unreadMessageButton.titleLabel?.minimumScaleFactor = 0.9
- unreadMessageButton.titleLabel?.numberOfLines = 1
- unreadMessageButton.titleLabel?.adjustsFontSizeToFitWidth = true
- unreadMessageButton.setTitle(NSLocalizedString("↓ New messages", comment: ""), for: .normal)
- unreadMessageButton.addAction { [weak self] in
- guard let self,
- let firstUnreadMessage = self.firstUnreadMessage,
- let indexPath = self.indexPath(for: firstUnreadMessage)
- else { return }
- self.tableView?.scrollToRow(at: indexPath, at: .none, animated: true)
- }
- self.view.addSubview(unreadMessageButton)
- return unreadMessageButton
- }()
- private lazy var scrollToBottomButton: UIButton = {
- let button = UIButton(frame: .init(x: 0, y: 0, width: 44, height: 44), primaryAction: UIAction { [weak self] _ in
- self?.tableView?.slk_scrollToBottom(animated: true)
- })
- button.backgroundColor = .secondarySystemBackground
- button.tintColor = .systemBlue
- button.layer.cornerRadius = button.frame.size.height / 2
- button.clipsToBounds = true
- button.alpha = 0
- button.translatesAutoresizingMaskIntoConstraints = false
- button.setImage(UIImage(systemName: "chevron.down"), for: .normal)
- self.view.addSubview(button)
- return button
- }()
- // MARK: - Init/Deinit
- public init?(for room: NCRoom) {
- super.init(for: room, tableViewStyle: .plain)
- self.hidesBottomBarWhenPushed = true
- self.tableView?.estimatedRowHeight = 0
- self.tableView?.estimatedSectionHeaderHeight = 0
- self.tableView?.prefetchDataSource = self
- FilePreviewImageView.setSharedImageDownloader(NCAPIController.sharedInstance().imageDownloader)
- NotificationCenter.default.addObserver(self, selector: #selector(willShowKeyboard(notification:)), name: UIWindow.keyboardWillShowNotification, object: nil)
- NotificationCenter.default.addObserver(self, selector: #selector(willHideKeyboard(notification:)), name: UIWindow.keyboardWillHideNotification, object: nil)
- AllocationTracker.shared.addAllocation("ChatViewController")
- }
- // Not using an optional here, because it is not available from ObjC
- // Pass "0" as highlightMessageId to not highlight a message
- public convenience init?(for room: NCRoom, withMessage messages: [NCChatMessage], withHighlightId highlightMessageId: Int) {
- self.init(for: room)
- // When we pass in a fixed number of messages, we hide the inputbar by default
- self.textInputbar.isHidden = true
- // Scroll to bottom manually after hiding the textInputbar, otherwise the
- // scrollToBottom button might be briefly visible even if not needed
- self.tableView?.slk_scrollToBottom(animated: false)
- self.appendMessages(messages: messages)
- self.tableView?.performBatchUpdates({
- self.tableView?.reloadData()
- }, completion: { _ in
- self.highlightMessageWithContentOffset(messageId: highlightMessageId)
- })
- }
- required init?(coder decoder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- AllocationTracker.shared.removeAllocation("ChatViewController")
- NSLog("Dealloc BaseChatViewController")
- }
- // MARK: - View lifecycle
- public override func viewDidLoad() {
- super.viewDidLoad()
- self.shouldScrollToBottomAfterKeyboardShows = false
- self.isInverted = false
- self.showSendMessageButton()
- self.leftButton.setImage(UIImage(systemName: "paperclip"), for: .normal)
- self.leftButton.accessibilityLabel = NSLocalizedString("Share a file from your Nextcloud", comment: "")
- self.leftButton.accessibilityHint = NSLocalizedString("Double tap to open file browser", comment: "")
- // Set delegate to retrieve typing events
- self.tableView?.separatorStyle = .none
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: chatMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: chatGroupedMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: chatReplyMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileGroupedMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: locationMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: locationGroupedMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: voiceMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: voiceGroupedMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: pollMessageCellIdentifier)
- self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: pollGroupedMessageCellIdentifier)
- self.tableView?.register(SystemMessageTableViewCell.self, forCellReuseIdentifier: SystemMessageCellIdentifier)
- self.tableView?.register(SystemMessageTableViewCell.self, forCellReuseIdentifier: InvisibleSystemMessageCellIdentifier)
- self.tableView?.register(MessageSeparatorTableViewCell.self, forCellReuseIdentifier: MessageSeparatorCellIdentifier)
- let newMessagesButtonText = NSLocalizedString("↓ New messages", comment: "")
- // Need to move down to NSLayout
- let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12)]
- let textSize = NSString(string: newMessagesButtonText).boundingRect(with: .init(width: 300, height: 24), options: .usesLineFragmentOrigin, attributes: attributes, context: nil)
- let buttonWidth = textSize.size.width + 20
- let views = [
- "unreadMessageButton": self.unreadMessageButton,
- "textInputbar": self.textInputbar,
- "scrollToBottomButton": self.scrollToBottomButton,
- "autoCompletionView": self.autoCompletionView
- ]
- let metrics = [
- "buttonWidth": buttonWidth
- ]
- self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:[unreadMessageButton(24)]-5-[autoCompletionView]", metrics: metrics, views: views))
- self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=0)-[unreadMessageButton(buttonWidth)]-(>=0)-|", metrics: metrics, views: views))
- if let view = self.view {
- self.view.addConstraint(NSLayoutConstraint(item: view, attribute: .centerX, relatedBy: .equal, toItem: self.unreadMessageButton, attribute: .centerX, multiplier: 1, constant: 0))
- }
- self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:[scrollToBottomButton(44)]-10-[autoCompletionView]", metrics: metrics, views: views))
- self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-(>=0)-[scrollToBottomButton(44)]-(>=0)-|", metrics: metrics, views: views))
- self.scrollToBottomButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true
- self.addMenuToLeftButton()
- self.replyMessageView?.addObserver(self, forKeyPath: "visible", options: .new, context: nil)
- }
- // swiftlint:disable:next block_based_kvo
- public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
- super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
- guard let object = object as? ReplyMessageView,
- object == self.replyMessageView else { return }
- // When the visible state of the replyMessageView changes, we need to update the toolbar to show the correct border
- // Only do this if we are not already at the bottom, otherwise we briefly show the scroll button directly after sending a message
- self.updateToolbar(animated: true)
- }
- public func updateToolbar(animated: Bool) {
- guard let tableView else { return }
- let animations = {
- let minimumOffset = (tableView.contentSize.height - tableView.frame.size.height) - 10
- if tableView.contentOffset.y < minimumOffset {
- // Scrolled -> show top border
- // When a reply view is visible, we show the border of that view
- if let replyMessageView = self.replyMessageView {
- replyMessageView.topBorder.isHidden = !replyMessageView.isVisible
- self.inputbarBorderView.isHidden = replyMessageView.isVisible
- } else {
- self.inputbarBorderView.isHidden = false
- }
- } else {
- // At the bottom -> no top border
- self.inputbarBorderView.isHidden = true
- if let replyMessageView = self.replyMessageView {
- replyMessageView.topBorder.isHidden = true
- }
- }
- }
- let animationsScrollButton = {
- let minimumOffset = (tableView.contentSize.height - tableView.frame.size.height) - 10
- if tableView.contentOffset.y < minimumOffset {
- // Scrolled -> show button
- self.scrollToBottomButton.alpha = 1
- } else {
- // At the bottom -> hide button
- self.scrollToBottomButton.alpha = 0
- }
- }
- if animated {
- self.animationDispatchQueue.async {
- self.animationDispatchGroup.enter()
- self.animationDispatchGroup.enter()
- DispatchQueue.main.async {
- UIView.transition(with: self.textInputbar,
- duration: 0.3,
- options: .transitionCrossDissolve,
- animations: animations) { _ in
- self.animationDispatchGroup.leave()
- }
- }
- DispatchQueue.main.async {
- UIView.animate(withDuration: 0.3,
- animations: animationsScrollButton) { _ in
- self.animationDispatchGroup.leave()
- }
- }
- _ = self.animationDispatchGroup.wait(timeout: .distantFuture)
- }
- } else {
- DispatchQueue.main.async {
- animations()
- animationsScrollButton()
- }
- }
- }
- public override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- self.isVisible = true
- }
- public override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- self.isVisible = false
- if !self.textInputbar.isHidden {
- self.savePendingMessage()
- }
- if dismissNotificationsOnViewWillDisappear {
- NotificationPresenter.shared().dismiss(animated: false)
- }
- }
- public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
- if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
- self.updateToolbar(animated: true)
- }
- }
- // MARK: - Keyboard notifications
- func willShowKeyboard(notification: Notification) {
- guard let currentResponder = UIResponder.slk_currentFirst() else { return }
- // Skip if it's not the emoji/date text field
- if !currentResponder.isKind(of: EmojiTextField.self) && !currentResponder.isKind(of: DatePickerTextField.self) {
- return
- }
- guard let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else {
- return
- }
- let keyboardRect = keyboardFrame.cgRectValue
- self.updateView(toShowOrHideEmojiKeyboard: keyboardRect.size.height)
- guard let interactingMessage,
- let indexPath = self.indexPath(for: interactingMessage) else {
- return
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
- if let tableView = self.tableView {
- let cellRect = tableView.rectForRow(at: indexPath)
- if !tableView.bounds.contains(cellRect) {
- self.tableView?.scrollToRow(at: indexPath, at: .bottom, animated: true)
- }
- }
- }
- }
- func willHideKeyboard(notification: Notification) {
- guard let currentResponder = UIResponder.slk_currentFirst() else { return }
- // Skip if it's not the emoji/date text field
- if !currentResponder.isKind(of: EmojiTextField.self) && !currentResponder.isKind(of: DatePickerTextField.self) {
- return
- }
- self.updateView(toShowOrHideEmojiKeyboard: 0.0)
- guard let lastMessageBeforeInteraction, let tableView else { return }
- if NCUtils.isValid(indexPath: lastMessageBeforeInteraction, forTableView: tableView) {
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
- tableView.scrollToRow(at: lastMessageBeforeInteraction, at: .bottom, animated: true)
- }
- }
- }
- // MARK: - Utils
- internal func getHeaderString(fromDate date: Date) -> String {
- let formatter = DateFormatter()
- formatter.dateStyle = .medium
- formatter.doesRelativeDateFormatting = true
- return formatter.string(from: date)
- }
- internal func presentWithNavigation(_ viewControllerToPresent: UIViewController, animated flag: Bool) {
- self.present(NCNavigationController(rootViewController: viewControllerToPresent), animated: flag)
- }
- // MARK: - Temporary messages
- internal func createTemporaryMessage(message: String, replyTo parentMessage: NCChatMessage?, messageParameters: String, silently: Bool) -> NCChatMessage {
- let temporaryMessage = NCChatMessage()
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- temporaryMessage.accountId = activeAccount.accountId
- temporaryMessage.actorDisplayName = activeAccount.userDisplayName
- temporaryMessage.actorId = activeAccount.userId
- temporaryMessage.actorType = "users"
- temporaryMessage.timestamp = Int(Date().timeIntervalSince1970)
- temporaryMessage.token = room.token
- temporaryMessage.message = self.replaceMentionsDisplayNamesWithMentionsKeysInMessage(message: message, parameters: messageParameters)
- let referenceId = "temp-\(Date().timeIntervalSince1970 * 1000)"
- temporaryMessage.referenceId = NCUtils.sha1(fromString: referenceId)
- temporaryMessage.internalId = referenceId
- temporaryMessage.isTemporary = true
- temporaryMessage.parentId = parentMessage?.internalId
- temporaryMessage.messageParametersJSONString = messageParameters
- temporaryMessage.isSilent = silently
- temporaryMessage.isMarkdownMessage = NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityMarkdownMessages, for: self.room)
- let realm = RLMRealm.default()
- try? realm.transaction {
- realm.add(temporaryMessage)
- }
- let unmanagedTemporaryMessage = NCChatMessage(value: temporaryMessage)
- return unmanagedTemporaryMessage
- }
- internal func replaceMessageMentionsKeysWithMentionsDisplayNames(message: String, parameters: String) -> String {
- var resultMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let messageParametersDict = NCMessageParameter.messageParametersDict(fromJSONString: parameters) else { return resultMessage }
- for (parameterKey, parameter) in messageParametersDict {
- let parameterKeyString = "{\(parameterKey)}"
- resultMessage = resultMessage.replacingOccurrences(of: parameterKeyString, with: parameter.mentionDisplayName)
- }
- return resultMessage
- }
- internal func appendTemporaryMessage(temporaryMessage: NCChatMessage) {
- DispatchQueue.main.async {
- let lastSectionBeforeUpdate = self.dateSections.count - 1
- self.appendMessages(messages: [temporaryMessage])
- if let lastDateSection = self.dateSections.last, let messagesForLastDate = self.messages[lastDateSection] {
- let lastMessageIndexPath = IndexPath(row: messagesForLastDate.count - 1, section: self.dateSections.count - 1)
- self.tableView?.beginUpdates()
- let newLastSection = self.dateSections.count - 1
- if lastSectionBeforeUpdate != newLastSection {
- self.tableView?.insertSections(.init(integer: newLastSection), with: .none)
- } else {
- self.tableView?.insertRows(at: [lastMessageIndexPath], with: .none)
- }
- self.tableView?.endUpdates()
- self.tableView?.scrollToRow(at: lastMessageIndexPath, at: .none, animated: true)
- }
- }
- }
- internal func removePermanentlyTemporaryMessage(temporaryMessage: NCChatMessage) {
- let realm = RLMRealm.default()
- try? realm.transaction {
- if let managedTemporaryMessage = NCChatMessage.objects(where: "referenceId = %@ AND isTemporary = true", temporaryMessage.referenceId).firstObject() {
- realm.delete(managedTemporaryMessage)
- }
- }
- self.removeTemporaryMessages(temporaryMessages: [temporaryMessage])
- }
- internal func removeTemporaryMessages(temporaryMessages: [NCChatMessage]) {
- DispatchQueue.main.async {
- for temporaryMessage in temporaryMessages {
- if let indexPath = self.indexPath(for: temporaryMessage) {
- self.removeMessage(at: indexPath)
- }
- }
- }
- }
- // MARK: - Message updates
- internal func modifyMessageWith(referenceId: String, block: (NCChatMessage) -> Void) {
- guard let (indexPath, message) = self.indexPathAndMessage(forReferenceId: referenceId)
- else { return }
- block(message)
- self.tableView?.beginUpdates()
- self.tableView?.reloadRows(at: [indexPath], with: .none)
- self.tableView?.endUpdates()
- }
- internal func updateMessage(withMessageId messageId: Int, updatedMessage: NCChatMessage) {
- DispatchQueue.main.async {
- guard let (indexPath, message) = self.indexPathAndMessage(forMessageId: messageId) else { return }
- var reloadIndexPaths = [indexPath]
- let isAtBottom = self.shouldScrollOnNewMessages()
- let keyDate = self.dateSections[indexPath.section]
- updatedMessage.isGroupMessage = message.isGroupMessage && message.actorType != "bots" && updatedMessage.lastEditTimestamp == 0
- self.messages[keyDate]?[indexPath.row] = updatedMessage
- // Check if there are any messages that reference our message as a parent -> these need to be reloaded as well
- if let visibleIndexPaths = self.tableView?.indexPathsForVisibleRows {
- let referencingIndexPaths = visibleIndexPaths.filter({
- guard let message = self.message(for: $0),
- let parentMessage = message.parent
- else { return false }
- return parentMessage.messageId == messageId
- })
- reloadIndexPaths.append(contentsOf: referencingIndexPaths)
- }
- self.tableView?.beginUpdates()
- self.tableView?.reloadRows(at: reloadIndexPaths, with: .none)
- self.tableView?.endUpdates()
- if isAtBottom {
- // Make sure we're really at the bottom after updating a message
- DispatchQueue.main.async {
- self.tableView?.slk_scrollToBottom(animated: false)
- self.updateToolbar(animated: false)
- }
- }
- }
- }
- // MARK: - User interface
- func showVoiceMessageRecordButton() {
- self.rightButton.setTitle("", for: .normal)
- self.rightButton.setImage(UIImage(systemName: "mic"), for: .normal)
- self.rightButton.tag = sendButtonTagVoice
- self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "")
- self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "")
- self.addGestureRecognizerToRightButton()
- }
- func showSendMessageButton() {
- self.rightButton.setTitle("", for: .normal)
- self.rightButton.setImage(UIImage(systemName: "paperplane"), for: .normal)
- self.rightButton.tag = sendButtonTagMessage
- self.rightButton.accessibilityLabel = NSLocalizedString("Send message", comment: "")
- self.rightButton.accessibilityHint = NSLocalizedString("Double tap to send message", comment: "")
- self.addMenuToRightButton()
- }
- // MARK: - Action methods
- func sendChatMessage(message: String, withParentMessage parentMessage: NCChatMessage?, messageParameters: String, silently: Bool) {
- // Overridden in sub class
- }
- func sendCurrentMessage(silently: Bool) {
- var replyToMessage: NCChatMessage?
- if let replyMessageView, replyMessageView.isVisible {
- replyToMessage = replyMessageView.message
- }
- let messageParameters = NCMessageParameter.messageParametersJSONString(from: self.mentionsDict) ?? ""
- self.sendChatMessage(message: self.textView.text, withParentMessage: replyToMessage, messageParameters: messageParameters, silently: silently)
- self.mentionsDict.removeAll()
- self.replyMessageView?.dismiss()
- super.didPressRightButton(self)
- self.clearPendingMessage()
- self.stopTyping(force: true)
- }
- public override func didPressRightButton(_ sender: Any?) {
- guard let button = sender as? UIButton else { return }
- switch button.tag {
- case sendButtonTagMessage:
- self.sendCurrentMessage(silently: false)
- super.didPressRightButton(sender)
- case sendButtonTagVoice:
- self.showVoiceMessageRecordHint()
- default:
- break
- }
- }
- func addGestureRecognizerToRightButton() {
- // Remove a potential menu so it does not interfere with the long gesture recognizer
- self.rightButton.menu = nil
- // Add long press gesture recognizer for voice message recording button
- self.voiceMessageLongPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressInVoiceMessageRecordButton(gestureRecognizer:)))
- if let voiceMessageLongPressGesture {
- voiceMessageLongPressGesture.delegate = self
- self.rightButton.addGestureRecognizer(voiceMessageLongPressGesture)
- }
- }
- func addMenuToRightButton() {
- // Remove a gesture recognizer to not interfere with our menu
- if let voiceMessageLongPressGesture = self.voiceMessageLongPressGesture {
- self.rightButton.removeGestureRecognizer(voiceMessageLongPressGesture)
- self.voiceMessageLongPressGesture = nil
- }
- let silentSendAction = UIAction(title: NSLocalizedString("Send without notification", comment: ""), image: UIImage(systemName: "bell.slash")) { [unowned self] _ in
- self.sendCurrentMessage(silently: true)
- }
- self.rightButton.menu = UIMenu(children: [silentSendAction])
- }
- func addMenuToLeftButton() {
- // The keyboard will be hidden when an action is invoked. Depending on what
- // attachment is shared, not resigning might lead to a currupted chat view
- var items: [UIAction] = []
- let cameraAction = UIAction(title: NSLocalizedString("Camera", comment: ""), image: UIImage(systemName: "camera")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.checkAndPresentCamera()
- }
- let photoLibraryAction = UIAction(title: NSLocalizedString("Photo Library", comment: ""), image: UIImage(systemName: "photo")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentPhotoLibrary()
- }
- let shareLocationAction = UIAction(title: NSLocalizedString("Location", comment: ""), image: UIImage(systemName: "location")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentShareLocation()
- }
- let contactShareAction = UIAction(title: NSLocalizedString("Contacts", comment: ""), image: UIImage(systemName: "person")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentShareContact()
- }
- let filesAction = UIAction(title: NSLocalizedString("Files", comment: ""), image: UIImage(systemName: "doc")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentDocumentPicker()
- }
- let ncFilesAction = UIAction(title: filesAppName, image: UIImage(named: "logo-action")?.withRenderingMode(.alwaysTemplate)) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentNextcloudFilesBrowser()
- }
- let pollAction = UIAction(title: NSLocalizedString("Poll", comment: ""), image: UIImage(systemName: "chart.bar")) { [unowned self] _ in
- self.textView.resignFirstResponder()
- self.presentPollCreation()
- }
- // Add actions (inverted)
- items.append(ncFilesAction)
- items.append(filesAction)
- items.append(contactShareAction)
- if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityLocationSharing, for: self.room) {
- items.append(shareLocationAction)
- }
- if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityTalkPolls, for: self.room),
- self.room.type != .oneToOne, self.room.type != .noteToSelf {
- items.append(pollAction)
- }
- items.append(photoLibraryAction)
- if UIImagePickerController.isSourceTypeAvailable(.camera) {
- items.append(cameraAction)
- }
- self.leftButton.menu = UIMenu(children: items)
- self.leftButton.showsMenuAsPrimaryAction = true
- }
- func presentNextcloudFilesBrowser() {
- let directoryVC = DirectoryTableViewController(path: "", inRoom: self.room.token)
- self.presentWithNavigation(directoryVC, animated: true)
- }
- func checkAndPresentCamera() {
- // https://stackoverflow.com/a/20464727/2512312
- let mediaType = AVMediaType.video
- let authStatus = AVCaptureDevice.authorizationStatus(for: mediaType)
- if authStatus == AVAuthorizationStatus.authorized {
- self.presentCamera()
- return
- } else if authStatus == AVAuthorizationStatus.notDetermined {
- AVCaptureDevice.requestAccess(for: mediaType, completionHandler: { (granted: Bool) in
- if granted {
- self.presentCamera()
- }
- })
- return
- }
- let alert = UIAlertController(title: NSLocalizedString("Could not access camera", comment: ""),
- message: NSLocalizedString("Camera access is not allowed. Check your settings.", comment: ""),
- preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default))
- NCUserInterfaceController.sharedInstance().presentAlertViewController(alert)
- }
- func presentCamera() {
- DispatchQueue.main.async {
- self.imagePicker = UIImagePickerController()
- if let imagePicker = self.imagePicker,
- let sourceType = UIImagePickerController.availableMediaTypes(for: imagePicker.sourceType) {
- imagePicker.sourceType = .camera
- imagePicker.cameraFlashMode = UIImagePickerController.CameraFlashMode(rawValue: NCUserDefaults.preferredCameraFlashMode()) ?? .off
- imagePicker.mediaTypes = sourceType
- imagePicker.delegate = self
- self.present(imagePicker, animated: true)
- }
- }
- }
- func presentPhotoLibrary() {
- DispatchQueue.main.async {
- var pickerConfig = PHPickerConfiguration()
- pickerConfig.selectionLimit = 5
- pickerConfig.filter = PHPickerFilter.any(of: [.images, .videos])
- self.photoPicker = PHPickerViewController(configuration: pickerConfig)
- if let photoPicker = self.photoPicker {
- photoPicker.delegate = self
- self.present(photoPicker, animated: true)
- }
- }
- }
- func presentPollCreation() {
- let pollCreationVC = PollCreationViewController(style: .insetGrouped)
- pollCreationVC.pollCreationDelegate = self
- self.presentWithNavigation(pollCreationVC, animated: true)
- }
- func presentShareLocation() {
- let shareLocationVC = ShareLocationViewController()
- shareLocationVC.delegate = self
- self.presentWithNavigation(shareLocationVC, animated: true)
- }
- func presentShareContact() {
- let contactPicker = CNContactPickerViewController()
- contactPicker.delegate = self
- self.present(contactPicker, animated: true)
- }
- func presentDocumentPicker() {
- DispatchQueue.main.async {
- let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
- documentPicker.delegate = self
- self.present(documentPicker, animated: true)
- }
- }
- func showReplyView(for message: NCChatMessage) {
- let isAtBottom = self.shouldScrollOnNewMessages()
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- if let replyProxyView = self.replyProxyView as? ReplyMessageView {
- self.replyMessageView = replyProxyView
- replyProxyView.presentReply(with: message, withUserId: activeAccount.userId)
- self.presentKeyboard(true)
- // Make sure we're really at the bottom after showing the replyMessageView
- if isAtBottom {
- self.tableView?.slk_scrollToBottom(animated: false)
- self.updateToolbar(animated: false)
- }
- }
- }
- func didPressReply(for message: NCChatMessage) {
- // Make sure we get a smooth animation after dismissing the context menu
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
- self.showReplyView(for: message)
- }
- }
- func didPressReplyPrivately(for message: NCChatMessage) {
- var userInfo: [String: String] = [:]
- userInfo["actorId"] = message.actorId
- NotificationCenter.default.post(name: .NCChatViewControllerReplyPrivatelyNotification, object: self, userInfo: userInfo)
- }
- func didPressAddReaction(for message: NCChatMessage, at indexPath: IndexPath) {
- // Hide the keyboard because we are going to present the emoji keyboard
- DispatchQueue.main.async {
- self.textView.resignFirstResponder()
- }
- DispatchQueue.main.async {
- self.interactingMessage = message
- self.lastMessageBeforeInteraction = self.tableView?.indexPathsForVisibleRows?.last
- if NCUtils.isiOSAppOnMac() {
- // Move the emojiTextField to the position of the cell
- if let rowRect = self.tableView?.rectForRow(at: indexPath),
- var convertedRowRect = self.tableView?.convert(rowRect, to: self.view) {
- // Show the emoji picker at the textView location of the cell
- convertedRowRect.origin.y += convertedRowRect.size.height - 16
- convertedRowRect.origin.x += 54
- // We don't want to have a clickable textField floating around
- convertedRowRect.size.width = 0
- convertedRowRect.size.height = 0
- // Remove and add the emojiTextField to the view, so the Mac OS emoji picker is always at the right location
- self.emojiTextField.removeFromSuperview()
- self.emojiTextField.frame = convertedRowRect
- self.view.addSubview(self.emojiTextField)
- }
- }
- self.emojiTextField.becomeFirstResponder()
- }
- }
- func didPressForward(for message: NCChatMessage) {
- var shareViewController: ShareViewController
- if message.isObjectShare {
- shareViewController = ShareViewController(toForwardObjectShare: message, fromChatViewController: self)
- } else {
- shareViewController = ShareViewController(toForwardMessage: message.parsedMessage().string, fromChatViewController: self)
- }
- shareViewController.delegate = self
- self.presentWithNavigation(shareViewController, animated: true)
- }
- func didPressNoteToSelf(for message: NCChatMessage) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().getNoteToSelfRoom(forAccount: activeAccount) { roomDict, error in
- if error == nil, let room = NCRoom(dictionary: roomDict, andAccountId: activeAccount.accountId) {
- if message.isObjectShare {
- NCAPIController.sharedInstance().shareRichObject(message.richObjectFromObjectShare, inRoom: room.token, for: activeAccount) { error in
- if error == nil {
- NotificationPresenter.shared().present(text: NSLocalizedString("Added note to self", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success)
- } else {
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while adding note", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- }
- }
- } else {
- NCAPIController.sharedInstance().sendChatMessage(message.parsedMessage().string, toRoom: room.token, displayName: nil, replyTo: -1, referenceId: nil, silently: false, for: activeAccount) { error in
- if error == nil {
- NotificationPresenter.shared().present(text: NSLocalizedString("Added note to self", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success)
- } else {
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while adding note", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- }
- }
- }
- } else {
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while adding note", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- }
- }
- }
- func didPressResend(for message: NCChatMessage) {
- // Make sure there's no unread message separator, as the indexpath could be invalid after removing a message
- self.removeUnreadMessagesSeparator()
- self.removePermanentlyTemporaryMessage(temporaryMessage: message)
- let originalMessage = self.replaceMessageMentionsKeysWithMentionsDisplayNames(message: message.message, parameters: message.messageParametersJSONString ?? "")
- self.sendChatMessage(message: originalMessage, withParentMessage: message.parent, messageParameters: message.messageParametersJSONString ?? "", silently: message.isSilent)
- }
- func didPressCopy(for message: NCChatMessage) {
- let pasteboard = UIPasteboard.general
- pasteboard.string = message.parsedMessage().string
- NotificationPresenter.shared().present(text: NSLocalizedString("Message copied", comment: ""), dismissAfterDelay: 5.0, includedStyle: .dark)
- }
- func didPressCopyLink(for message: NCChatMessage) {
- guard let link = room.linkURL else {
- return
- }
- let url = "\(link)#message_\(message.messageId)"
- let pasteboard = UIPasteboard.general
- pasteboard.string = url
- NotificationPresenter.shared().present(text: NSLocalizedString("Message link copied", comment: ""), dismissAfterDelay: 5.0, includedStyle: .dark)
- }
- func didPressTranslate(for message: NCChatMessage) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- let translateMessageVC = MessageTranslationViewController(message: message.parsedMessage().string, availableTranslations: NCDatabaseManager.sharedInstance().availableTranslations(forAccountId: activeAccount.accountId))
- self.presentWithNavigation(translateMessageVC, animated: true)
- }
- func didPressTranscribeVoiceMessage(for message: NCChatMessage) {
- let downloader = NCChatFileController()
- downloader.delegate = self
- downloader.messageType = kMessageTypeVoiceMessage
- downloader.actionType = actionTypeTranscribeVoiceMessage
- downloader.downloadFile(fromMessage: message.file())
- }
- func didPressEdit(for message: NCChatMessage) {
- self.savePendingMessage()
- let warningString = NSLocalizedString("Adding a mention will only notify users that did not read the message yet", comment: "")
- let warningView = UIView()
- let warningLabel = UILabel()
- warningView.addSubview(warningLabel)
- warningLabel.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- warningLabel.leftAnchor.constraint(equalTo: warningView.safeAreaLayoutGuide.leftAnchor, constant: 8),
- warningLabel.rightAnchor.constraint(equalTo: warningView.safeAreaLayoutGuide.rightAnchor, constant: -8),
- warningLabel.topAnchor.constraint(equalTo: warningView.safeAreaLayoutGuide.topAnchor, constant: 4),
- warningLabel.bottomAnchor.constraint(equalTo: warningView.safeAreaLayoutGuide.bottomAnchor)
- ])
- let attributedWarningString = warningString.withFont(.systemFont(ofSize: 14)).withTextColor(.secondaryLabel)
- warningLabel.attributedText = attributedWarningString
- warningLabel.numberOfLines = 0
- // Calculate the height needed to completely show the text
- let maxWidth = self.autoCompletionView.frame.width - autoCompletionView.safeAreaInsets.left - autoCompletionView.safeAreaInsets.right - 16
- let contraintRect = CGSize(width: maxWidth, height: .greatestFiniteMagnitude)
- let size = attributedWarningString.boundingRect(with: contraintRect, options: .usesLineFragmentOrigin, context: nil)
- // Update the frame for the new height and include the top padding
- warningView.frame = .init(x: 0, y: 0, width: size.width, height: ceil(size.height) + 4)
- self.autoCompletionView.tableHeaderView = warningView
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
- // Show the message to edit in the reply view
- self.showReplyView(for: message)
- self.replyMessageView!.hideCloseButton()
- self.mentionsDict = [:]
- // Try to reconstruct the mentionsDict
- for (key, value) in message.messageParameters {
- if let key = key as? String,
- key.hasPrefix("mention-"),
- let value = value as? [String: String] {
- guard let parameter = NCMessageParameter(dictionary: value),
- let paramaterDisplayName = parameter.name,
- let parameterId = parameter.parameterId
- else { continue }
- // For mentions the displayName is in the parameter "name", in our mentionsDict we use
- // "mentionsDisplayName" for the displayName with the prefix "@", so we need to construct
- // that manually here, so mentions are correctly removed while editing.
- // The same needs to happen for "mentionId" -> userId with a prefixed "@"
- parameter.mentionDisplayName = "@\(paramaterDisplayName)"
- parameter.mentionId = "@\(parameterId)"
- self.mentionsDict[key] = parameter
- }
- }
- self.editingMessage = message
- // For files without a caption we start with an empty text instead of "{file}"
- if message.message == "{file}", message.file() != nil {
- self.editText("")
- } else {
- self.editText(message.parsedMessage().string)
- }
- }
- }
- func didPressDelete(for message: NCChatMessage) {
- if message.sendingFailed || message.isOfflineMessage {
- self.removePermanentlyTemporaryMessage(temporaryMessage: message)
- return
- }
- if let deletingMessage = message.copy() as? NCChatMessage {
- deletingMessage.message = NSLocalizedString("Deleting message", comment: "")
- deletingMessage.isDeleting = true
- self.updateMessage(withMessageId: deletingMessage.messageId, updatedMessage: deletingMessage)
- }
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().deleteChatMessage(inRoom: self.room.token, withMessageId: message.messageId, for: activeAccount) { messageDict, error, statusCode in
- if error == nil,
- let messageDict,
- let parent = messageDict["parent"] as? [AnyHashable: Any] {
- if statusCode == 202 {
- self.view.makeToast(NSLocalizedString("Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services", comment: ""), duration: 5, position: CSToastPositionCenter)
- } else if statusCode == 200 {
- NotificationPresenter.shared().present(text: NSLocalizedString("Message deleted successfully", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success)
- }
- if let deleteMessage = NCChatMessage(dictionary: parent, andAccountId: activeAccount.accountId) {
- self.updateMessage(withMessageId: deleteMessage.messageId, updatedMessage: deleteMessage)
- }
- } else if error != nil {
- switch statusCode {
- case 400:
- NotificationPresenter.shared().present(text: NSLocalizedString("Message could not be deleted because it is too old", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- case 405:
- NotificationPresenter.shared().present(text: NSLocalizedString("Only normal chat messages can be deleted", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- default:
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while deleting the message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- }
- self.updateMessage(withMessageId: message.messageId, updatedMessage: message)
- }
- }
- }
- func didPressOpenInNextcloud(for message: NCChatMessage) {
- if let file = message.file(), let path = file.path, let link = file.link {
- NCUtils.openFileInNextcloudAppOrBrowser(path: path, withFileLink: link)
- }
- }
- // MARK: - Editing support
- public override func didCancelTextEditing(_ sender: Any) {
- super.didCancelTextEditing(sender)
- self.autoCompletionView.tableHeaderView = nil
- self.replyMessageView?.dismiss()
- self.mentionsDict.removeAll()
- self.editingMessage = nil
- self.restorePendingMessage()
- }
- public override func didCommitTextEditing(_ sender: Any) {
- super.didCommitTextEditing(sender)
- self.autoCompletionView.tableHeaderView = nil
- self.replyMessageView?.dismiss()
- self.mentionsDict.removeAll()
- self.editingMessage = nil
- self.restorePendingMessage()
- }
- // MARK: - UITextField delegate
- public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
- if textField == self.emojiTextField, self.interactingMessage != nil {
- self.interactingMessage = nil
- textField.resignFirstResponder()
- }
- return true
- }
- public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
- if textField == self.emojiTextField, string.isSingleEmoji, let interactingMessage = self.interactingMessage {
- self.addReaction(reaction: string, to: interactingMessage)
- textField.resignFirstResponder()
- }
- return true
- }
- // MARK: - UITextViewDelegate
- public override func textViewDidChange(_ textView: UITextView) {
- self.startTyping()
- }
- // MARK: - TypingIndicator support
- func sendStartedTypingMessage(to sessionId: String) {
- // Workaround: TypingPrivacy should be checked locally, not from the remote server, use serverCapabilities for now
- // TODO: Remove workaround for federated typing indicators.
- guard let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId)
- else { return }
- if serverCapabilities.typingPrivacy {
- return
- }
- if let signalingController = NCSettingsController.sharedInstance().externalSignalingController(forAccountId: self.room.accountId) {
- let mySessionId = signalingController.sessionId()
- let message = NCStartedTypingMessage(from: mySessionId, sendTo: sessionId, withPayload: [:], forRoomType: "")
- signalingController.sendCall(message)
- }
- }
- func sendStartedTypingMessageToAll() {
- // Workaround: TypingPrivacy should be checked locally, not from the remote server, use serverCapabilities for now
- // TODO: Remove workaround for federated typing indicators.
- guard let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId),
- !serverCapabilities.typingPrivacy,
- let signalingController = NCSettingsController.sharedInstance().externalSignalingController(forAccountId: self.room.accountId)
- else { return }
- let participantMap = signalingController.getParticipantMap()
- let mySessionId = signalingController.sessionId()
- for (key, _) in participantMap {
- if let sessionId = key as? String {
- let message = NCStartedTypingMessage(from: mySessionId, sendTo: sessionId, withPayload: [:], forRoomType: "")
- signalingController.sendCall(message)
- }
- }
- }
- func sendStoppedTypingMessageToAll() {
- guard let serverCapabilities = NCDatabaseManager.sharedInstance().roomTalkCapabilities(for: self.room),
- !serverCapabilities.typingPrivacy,
- let signalingController = NCSettingsController.sharedInstance().externalSignalingController(forAccountId: self.room.accountId)
- else { return }
- let participantMap = signalingController.getParticipantMap()
- let mySessionId = signalingController.sessionId()
- for (key, _) in participantMap {
- if let sessionId = key as? String {
- let message = NCStoppedTypingMessage(from: mySessionId, sendTo: sessionId, withPayload: [:], forRoomType: "")
- signalingController.sendCall(message)
- }
- }
- }
- func startTyping() {
- if !self.isTyping {
- self.isTyping = true
- self.sendStartedTypingMessageToAll()
- self.setTypingTimer()
- }
- self.setStopTypingTimer()
- }
- func stopTyping(force: Bool) {
- if self.isTyping || force {
- self.isTyping = false
- self.sendStoppedTypingMessageToAll()
- self.invalidateStopTypingTimer()
- self.invalidateTypingTimer()
- }
- }
- // TypingTimer is used to continously send "startedTyping" messages, while we are typing
- func setTypingTimer() {
- self.invalidateTypingTimer()
- self.typingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false, block: { [weak self] _ in
- guard let self else { return }
- if self.isTyping {
- // We're still typing, send signaling message again to all participants
- self.sendStartedTypingMessageToAll()
- self.setTypingTimer()
- } else {
- // We stopped typing, we don't send anything to the participants, we just remove our timer
- self.invalidateTypingTimer()
- }
- })
- }
- func invalidateTypingTimer() {
- self.typingTimer?.invalidate()
- self.typingTimer = nil
- }
- // StopTypingTimer is used to detect when we stop typing (locally)
- func setStopTypingTimer() {
- self.invalidateStopTypingTimer()
- self.stopTypingTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false, block: { [weak self] _ in
- guard let self else { return }
- if self.isTyping {
- self.isTyping = false
- self.invalidateStopTypingTimer()
- }
- })
- }
- func invalidateStopTypingTimer() {
- self.stopTypingTimer?.invalidate()
- self.stopTypingTimer = nil
- }
- func addTypingIndicator(withUserIdentifier userIdentifier: String, andDisplayName displayName: String) {
- DispatchQueue.main.async {
- if let view = self.textInputbar.typingView as? TypingIndicatorView {
- view.addTyping(userIdentifier: userIdentifier, displayName: displayName)
- }
- }
- }
- func removeTypingIndicator(withUserIdentifier userIdentifier: String) {
- DispatchQueue.main.async {
- if let view = self.textInputbar.typingView as? TypingIndicatorView {
- view.removeTyping(userIdentifier: userIdentifier)
- }
- }
- }
- // MARK: - ShareConfirmationViewController delegate & helper
- public func shareConfirmationViewControllerDidFailed(_ viewController: ShareConfirmationViewController) {
- self.dismiss(animated: true) {
- if viewController.forwardingMessage {
- NotificationPresenter.shared().present(text: NSLocalizedString("Failed to forward message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- }
- }
- }
- public func shareConfirmationViewControllerDidFinish(_ viewController: ShareConfirmationViewController) {
- self.dismiss(animated: true) {
- if viewController.forwardingMessage {
- var userInfo: [String: String] = [:]
- userInfo["token"] = viewController.room.token
- userInfo["accountId"] = viewController.account.accountId
- NotificationCenter.default.post(name: .NCChatViewControllerForwardNotification, object: self, userInfo: userInfo)
- }
- }
- }
- internal func createShareConfirmationViewController() -> (shareConfirmationVC: ShareConfirmationViewController, navController: NCNavigationController) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: activeAccount.accountId)
- let shareConfirmationVC = ShareConfirmationViewController(room: self.room, account: activeAccount, serverCapabilities: serverCapabilities!)!
- shareConfirmationVC.delegate = self
- shareConfirmationVC.isModal = true
- let navigationController = NCNavigationController(rootViewController: shareConfirmationVC)
- return (shareConfirmationVC, navigationController)
- }
- // MARK: - ShareViewController Delegate
- public func shareViewControllerDidCancel(_ viewController: ShareViewController) {
- self.dismiss(animated: true)
- }
- // MARK: - PHPhotoPicker Delegate
- public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
- if results.isEmpty {
- picker.dismiss(animated: true)
- return
- }
- let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController()
- picker.dismiss(animated: true) {
- self.present(navigationController, animated: true) {
- for result in results {
- result.itemProvider.loadItem(forTypeIdentifier: "public.image", options: nil) { item, error in
- guard error == nil, let item = item as? URL else { return }
- var fileName: String
- if let suggestedFileName = result.itemProvider.suggestedName {
- fileName = "\(suggestedFileName).jpg"
- } else {
- fileName = "IMG_\(String(Date().timeIntervalSince1970 * 1000)).jpg"
- }
- shareConfirmationVC.shareItemController.addItem(withURLAndName: item, withName: fileName)
- }
- result.itemProvider.loadItem(forTypeIdentifier: "public.movie", options: nil) { item, error in
- guard error == nil, let item = item as? URL else { return }
- var fileName: String
- if let suggestedFileName = result.itemProvider.suggestedName {
- fileName = "\(suggestedFileName).mov"
- } else {
- fileName = "VID_\(String(Date().timeIntervalSince1970 * 1000)).mov"
- }
- shareConfirmationVC.shareItemController.addItem(withURLAndName: item, withName: fileName)
- }
- }
- }
- }
- }
- // MARK: - UIImagePickerController delegate
- public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
- self.saveImagePickerSettings(picker)
- let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController()
- guard let mediaType = info[.mediaType] as? String else { return }
- if mediaType == "public.image" {
- guard let image = info[.originalImage] as? UIImage else { return }
- self.dismiss(animated: true) {
- self.present(navigationController, animated: true) {
- shareConfirmationVC.shareItemController.addItem(with: image)
- }
- }
- } else if mediaType == "public.movie" {
- guard let imageUrl = info[.mediaURL] as? URL else { return }
- self.dismiss(animated: true) {
- self.present(navigationController, animated: true) {
- shareConfirmationVC.shareItemController.addItem(with: imageUrl)
- }
- }
- }
- }
- public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
- self.saveImagePickerSettings(picker)
- self.dismiss(animated: true)
- }
- public func saveImagePickerSettings(_ picker: UIImagePickerController) {
- if picker.sourceType == .camera && picker.cameraCaptureMode == .photo {
- NCUserDefaults.setPreferredCameraFlashMode(picker.cameraFlashMode.rawValue)
- }
- }
- // MARK: - UIDocumentPickerViewController Delegate
- public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
- let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController()
- self.present(navigationController, animated: true) {
- for url in urls {
- shareConfirmationVC.shareItemController.addItem(with: url)
- }
- }
- }
- // MARK: - ShareLocationViewController Delegate
- public func shareLocationViewController(_ viewController: ShareLocationViewController, didSelectLocationWithLatitude latitude: Double, longitude: Double, andName name: String) {
- let richObject = GeoLocationRichObject(latitude: latitude, longitude: longitude, name: name)
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().shareRichObject(richObject.richObjectDictionary(), inRoom: self.room.token, for: activeAccount) { error in
- if let error {
- print("Error sharing rich object: \(error)")
- }
- }
- viewController.dismiss(animated: true)
- }
- // MARK: - CNContactPickerViewController Delegate
- public func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
- guard let vCardData = try? CNContactVCardSerialization.data(with: [contact]) else { return }
- var vcString = String(data: vCardData, encoding: .utf8)
- if let imageData = contact.imageData {
- let base64Image = imageData.base64EncodedString()
- let vcardImageString = "PHOTO;TYPE=JPEG;ENCODING=BASE64:\(base64Image)\n"
- vcString = vcString?.replacingOccurrences(of: "END:VCARD", with: vcardImageString + "END:VCARD")
- }
- let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
- let folderPath = paths[0]
- let filePath = (folderPath as NSString).appendingPathComponent("contact.vcf")
- do {
- try vcString?.write(toFile: filePath, atomically: true, encoding: .utf8)
- let url = URL(fileURLWithPath: filePath)
- let contactFileName = "\(contact.identifier).vcf"
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().uniqueNameForFileUpload(withName: contactFileName, originalName: true, for: activeAccount) { fileServerURL, fileServerPath, _, _ in
- if let fileServerURL, let fileServerPath {
- self.uploadFileAtPath(localPath: url.path, withFileServerURL: fileServerURL, andFileServerPath: fileServerPath, withMetaData: nil)
- } else {
- print("Could not find unique name for contact file")
- }
- }
- } catch {
- print("Could not write contact file")
- }
- }
- // MARK: - Voice messages recording
- func showVoiceMessageRecordHint() {
- let toastPosition = CGPoint(x: self.textInputbar.center.x, y: self.textInputbar.center.y - self.textInputbar.frame.size.height)
- self.view.makeToast(NSLocalizedString("Tap and hold to record a voice message, release the button to send it.", comment: ""), duration: 3, position: toastPosition)
- }
- func showVoiceMessageRecordingView() {
- self.voiceMessageRecordingView = VoiceMessageRecordingView()
- guard let voiceMessageRecordingView = self.voiceMessageRecordingView else { return }
- voiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false
- self.textInputbar.addSubview(voiceMessageRecordingView)
- self.textInputbar.bringSubviewToFront(voiceMessageRecordingView)
- let views = [
- "voiceMessageRecordingView": voiceMessageRecordingView
- ]
- let metrics = [
- "buttonWidth": self.rightButton.frame.size.width
- ]
- self.textInputbar.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[voiceMessageRecordingView]|", metrics: metrics, views: views))
- self.textInputbar.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[voiceMessageRecordingView(>=0)]-(buttonWidth)-|", metrics: metrics, views: views))
- }
- func hideVoiceMessageRecordingView() {
- self.voiceMessageRecordingView?.isHidden = true
- }
- func setupAudioRecorder() {
- guard let userDocumentDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).last,
- let outputFileURL = NSURL.fileURL(withPathComponents: [userDocumentDirectory, "voice-message-recording.m4a"])
- else { return }
- let session = AVAudioSession.sharedInstance()
- try? session.setCategory(.playAndRecord)
- let settings = [
- AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
- AVSampleRateKey: 44100,
- AVNumberOfChannelsKey: 2
- ]
- self.recorder = try? AVAudioRecorder(url: outputFileURL, settings: settings)
- self.recorder?.delegate = self
- self.recorder?.isMeteringEnabled = true
- self.recorder?.prepareToRecord()
- }
- func checkPermissionAndRecordVoiceMessage() {
- let mediaType = AVMediaType.audio
- let authStatus = AVCaptureDevice.authorizationStatus(for: mediaType)
- if authStatus == AVAuthorizationStatus.authorized {
- self.startRecordingVoiceMessage()
- return
- } else if authStatus == AVAuthorizationStatus.notDetermined {
- AVCaptureDevice.requestAccess(for: mediaType, completionHandler: { granted in
- NSLog("Microphone permission granted: %@", granted ? "YES" : "NO")
- })
- return
- }
- let alert = UIAlertController(title: NSLocalizedString("Could not access microphone", comment: ""),
- message: NSLocalizedString("Microphone access is not allowed. Check your settings.", comment: ""),
- preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default))
- NCUserInterfaceController.sharedInstance().presentAlertViewController(alert)
- }
- func startRecordingVoiceMessage() {
- self.setupAudioRecorder()
- self.showVoiceMessageRecordingView()
- if let recorder = self.recorder, !recorder.isRecording {
- let session = AVAudioSession.sharedInstance()
- try? session.setActive(true)
- recorder.record()
- }
- }
- func stopRecordingVoiceMessage() {
- self.hideVoiceMessageRecordingView()
- if let recorder = self.recorder, recorder.isRecording {
- recorder.stop()
- let session = AVAudioSession.sharedInstance()
- try? session.setActive(false)
- }
- }
- func shareVoiceMessage() {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
- let dateString = dateFormatter.string(from: Date())
- // Replace chars that are not allowed on the filesystem
- let notAllowedCharSet = CharacterSet(charactersIn: "\\/:%")
- var roomString = self.room.displayName.components(separatedBy: notAllowedCharSet).joined(separator: " ")
- // Replace multiple spaces with 1
- if let regex = try? NSRegularExpression(pattern: " +") {
- roomString = regex.stringByReplacingMatches(in: roomString, range: .init(location: 0, length: roomString.count), withTemplate: " ")
- }
- var audioFileName = "Talk recording from \(dateString) (\(roomString))"
- // Trim the file name if too long
- if audioFileName.count > 146 {
- audioFileName = String(audioFileName.prefix(146))
- }
- audioFileName += ".mp3"
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().uniqueNameForFileUpload(withName: audioFileName, originalName: true, for: activeAccount, withCompletionBlock: { fileServerURL, fileServerPath, _, _ in
- if let fileServerURL, let fileServerPath, let recorder = self.recorder {
- let talkMetaData: [String: String] = ["messageType": "voice-message"]
- self.uploadFileAtPath(localPath: recorder.url.path, withFileServerURL: fileServerURL, andFileServerPath: fileServerPath, withMetaData: talkMetaData)
- } else {
- NSLog("Could not find unique name for voice message file.")
- }
- })
- }
- func uploadFileAtPath(localPath: String, withFileServerURL fileServerURL: String, andFileServerPath fileServerPath: String, withMetaData talkMetaData: [String: String]?) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().setupNCCommunication(for: activeAccount)
- NextcloudKit.shared.upload(serverUrlFileName: fileServerURL, fileNameLocalPath: localPath, taskHandler: { _ in
- NSLog("Upload task")
- }, progressHandler: { progress in
- NSLog("Progress:%f", progress.fractionCompleted)
- }, completionHandler: { _, _, _, _, _, _, _, error in
- NSLog("Upload completed with error code: %ld", error.errorCode)
- if error.errorCode == 0 {
- NCAPIController.sharedInstance().shareFileOrFolder(for: activeAccount, atPath: fileServerPath, toRoom: self.room.token, talkMetaData: talkMetaData, withCompletionBlock: { error in
- if error != nil {
- NSLog("Failed to share voice message")
- }
- })
- } else if error.errorCode == 404 || error.errorCode == 409 {
- NCAPIController.sharedInstance().checkOrCreateAttachmentFolder(for: activeAccount, withCompletionBlock: { created, _ in
- if created {
- self.uploadFileAtPath(localPath: localPath, withFileServerURL: fileServerURL, andFileServerPath: fileServerPath, withMetaData: talkMetaData)
- } else {
- NSLog("Failed to check or create attachment folder")
- }
- })
- } else {
- NSLog("Failed upload voice message")
- }
- })
- }
- // MARK: - AVAudioRecorder Delegate
- public func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
- if flag, recorder == self.recorder, !self.recordCancelled {
- self.shareVoiceMessage()
- }
- }
- // MARK: - Voice Messages Transcribe
- func transcribeVoiceMessage(with fileStatus: NCChatFileStatus) {
- guard let fileLocalPath = fileStatus.fileLocalPath else { return }
- DispatchQueue.main.async {
- let audioFileURL = URL(fileURLWithPath: fileLocalPath)
- let viewController = VoiceMessageTranscribeViewController(audiofileUrl: audioFileURL)
- let navController = NCNavigationController(rootViewController: viewController)
- self.present(navController, animated: true)
- }
- }
- // MARK: - Voice Message Player
- func setupVoiceMessagePlayer(with fileStatus: NCChatFileStatus) {
- guard let fileLocalPath = fileStatus.fileLocalPath,
- let data = try? Data(contentsOf: URL(fileURLWithPath: fileLocalPath)),
- let player = try? AVAudioPlayer(data: data)
- else { return }
- self.voiceMessagesPlayer = player
- self.playerAudioFileStatus = fileStatus
- player.delegate = self
- self.playVoiceMessagePlayer()
- }
- func playVoiceMessagePlayer() {
- self.setSpeakerAudioSession()
- self.enableProximitySensor()
- self.startVoiceMessagePlayerTimer()
- self.voiceMessagesPlayer?.play()
- }
- func pauseVoiceMessagePlayer() {
- self.disableProximitySensor()
- self.stopVoiceMessagePlayerTimer()
- self.voiceMessagesPlayer?.pause()
- self.checkVisibleCellAudioPlayers()
- }
- func stopVoiceMessagePlayer() {
- self.disableProximitySensor()
- self.stopVoiceMessagePlayerTimer()
- self.voiceMessagesPlayer?.stop()
- }
- func enableProximitySensor() {
- NotificationCenter.default.addObserver(self, selector: #selector(sensorStateChange(notification:)), name: UIDevice.proximityStateDidChangeNotification, object: nil)
- UIDevice.current.isProximityMonitoringEnabled = true
- }
- func disableProximitySensor() {
- if UIDevice.current.proximityState == false {
- // Only disable monitoring if proximity sensor state is not active.
- // If not proximity sensor state is cached as active and next time we enable monitoring
- // sensorStateChange won't be trigger until proximity sensor state changes to inactive.
- NotificationCenter.default.removeObserver(self, name: UIDevice.proximityStateDidChangeNotification, object: nil)
- UIDevice.current.isProximityMonitoringEnabled = false
- }
- }
- func setSpeakerAudioSession() {
- let session = AVAudioSession.sharedInstance()
- try? session.setCategory(AVAudioSession.Category.playback)
- try? session.setActive(true)
- }
- func setVoiceChatAudioSession() {
- let session = AVAudioSession.sharedInstance()
- try? session.setCategory(AVAudioSession.Category.playAndRecord, mode: AVAudioSession.Mode.voiceChat)
- try? session.setActive(true)
- }
- func sensorStateChange(notification: Notification) {
- if UIDevice.current.proximityState {
- self.setVoiceChatAudioSession()
- } else {
- self.pauseVoiceMessagePlayer()
- self.setSpeakerAudioSession()
- self.disableProximitySensor()
- }
- }
- func checkVisibleCellAudioPlayers() {
- guard let tableView = self.tableView,
- let indexPaths = tableView.indexPathsForVisibleRows,
- let playerAudioFileStatus = self.playerAudioFileStatus,
- let voiceMessagesPlayer = self.voiceMessagesPlayer
- else { return }
- for indexPath in indexPaths {
- let sectionDate = self.dateSections[indexPath.section]
- if let messages = self.messages[sectionDate] {
- let message = messages[indexPath.row]
- if message.isVoiceMessage {
- guard let cell = tableView.cellForRow(at: indexPath) as? BaseChatTableViewCell,
- let file = message.file()
- else { continue }
- if file.parameterId == playerAudioFileStatus.fileId, file.path == playerAudioFileStatus.filePath {
- cell.audioPlayerView?.setPlayerProgress(voiceMessagesPlayer.currentTime, isPlaying: voiceMessagesPlayer.isPlaying, maximumValue: voiceMessagesPlayer.duration)
- continue
- }
- cell.audioPlayerView?.resetPlayer()
- }
- }
- }
- }
- func startVoiceMessagePlayerTimer() {
- self.stopVoiceMessagePlayerTimer()
- self.playerProgressTimer = Timer.scheduledTimer(timeInterval: 0.05, target: self, selector: #selector(checkVisibleCellAudioPlayers), userInfo: nil, repeats: true)
- }
- func stopVoiceMessagePlayerTimer() {
- self.playerProgressTimer?.invalidate()
- self.playerProgressTimer = nil
- }
- // MARK: - AVAudioPlayer Delegate
- public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
- self.stopVoiceMessagePlayerTimer()
- self.checkVisibleCellAudioPlayers()
- self.disableProximitySensor()
- }
- // MARK: - Gesture recognizer
- public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
- if gestureRecognizer == self.voiceMessageLongPressGesture {
- return true
- }
- return super.gestureRecognizerShouldBegin(gestureRecognizer)
- }
- func handleLongPressInVoiceMessageRecordButton(gestureRecognizer: UILongPressGestureRecognizer) {
- if self.rightButton.tag != sendButtonTagVoice {
- return
- }
- let point = gestureRecognizer.location(in: self.view)
- if gestureRecognizer.state == .began {
- print("Start recording audio message")
- // 'Pop' feedback (strong boom)
- AudioServicesPlaySystemSound(1520)
- self.checkPermissionAndRecordVoiceMessage()
- self.shouldLockInterfaceOrientation(lock: true)
- self.recordCancelled = false
- self.longPressStartingPoint = point
- self.cancelHintLabelInitialPositionX = voiceMessageRecordingView?.slideToCancelHintLabel?.frame.origin.x
- } else if gestureRecognizer.state == .ended {
- print("Stop recording audio message")
- self.shouldLockInterfaceOrientation(lock: false)
- if let recordingTime = self.recorder?.currentTime {
- // Mark record as cancelled if audio message is no longer than one second
- self.recordCancelled = recordingTime < 1
- }
- self.stopRecordingVoiceMessage()
- } else if gestureRecognizer.state == .changed {
- guard let longPressStartingPoint,
- let cancelHintLabelInitialPositionX,
- let voiceMessageRecordingView,
- let slideToCancelHintLabel = voiceMessageRecordingView.slideToCancelHintLabel
- else { return }
- let slideX = longPressStartingPoint.x - point.x
- // Only slide view to the left
- if slideX > 0 {
- let maxSlideX = 100.0
- var labelFrame = slideToCancelHintLabel.frame
- labelFrame = .init(x: cancelHintLabelInitialPositionX - slideX, y: labelFrame.origin.y, width: labelFrame.size.width, height: labelFrame.size.height)
- slideToCancelHintLabel.frame = labelFrame
- slideToCancelHintLabel.alpha = (maxSlideX - slideX) / 100
- // Cancel recording if slided more than maxSlideX
- if slideX > maxSlideX, !self.recordCancelled {
- print("Cancel recording audio message")
- // 'Cancelled' feedback (three sequential weak booms)
- AudioServicesPlaySystemSound(1521)
- self.recordCancelled = true
- self.stopRecordingVoiceMessage()
- }
- }
- } else if gestureRecognizer.state == .cancelled || gestureRecognizer.state == .failed {
- print("Gesture cancelled or failed -> Cancel recording audio message")
- self.shouldLockInterfaceOrientation(lock: false)
- self.recordCancelled = false
- self.stopRecordingVoiceMessage()
- }
- }
- func shouldLockInterfaceOrientation(lock: Bool) {
- if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
- appDelegate.shouldLockInterfaceOrientation = lock
- }
- }
- // MARK: - UIScrollViewDelegate methods
- public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
- super.scrollViewDidEndDecelerating(scrollView)
- guard scrollView == self.tableView
- else { return }
- if self.firstUnreadMessage != nil {
- self.checkUnreadMessagesVisibility()
- }
- self.updateToolbar(animated: true)
- }
- public override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
- super.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate)
- guard scrollView == self.tableView
- else { return }
- if !decelerate, self.firstUnreadMessage != nil {
- self.checkUnreadMessagesVisibility()
- }
- self.updateToolbar(animated: true)
- }
- public override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
- guard scrollView == self.tableView
- else { return }
- if self.firstUnreadMessage != nil {
- self.checkUnreadMessagesVisibility()
- }
- self.updateToolbar(animated: true)
- }
- // MARK: - UITextViewDelegate methods
- public override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
- // Do not allow to type while recording
- if let voiceMessageLongPressGesture,
- voiceMessageLongPressGesture.state != .possible {
- return false
- }
- return super.textView(textView, shouldChangeTextIn: range, replacementText: text)
- }
- // MARK: - Chat functions
- func prependMessages(historyMessages: [NCChatMessage], addingBlockSeparator shouldAddBlockSeparator: Bool) -> IndexPath? {
- var historyDict: [Date: [NCChatMessage]] = [:]
- self.internalAppendMessages(messages: historyMessages, inDictionary: &historyDict)
- var chatSection: Date?
- var historyMessagesForSection: [NCChatMessage]?
- // Sort history sections
- let historySections = historyDict.keys.sorted()
- // Add every section in history that can't be merged with current chat messages
- for historySection in historySections {
- historyMessagesForSection = historyDict[historySection]
- chatSection = self.getKeyForDate(date: historySection, inDictionary: self.messages)
- if chatSection == nil {
- self.messages[historySection] = historyMessagesForSection
- }
- }
- self.sortDateSections()
- if shouldAddBlockSeparator {
- // Chat block separator
- let blockSeparatorMessage = NCChatMessage()
- blockSeparatorMessage.messageId = kChatBlockSeparatorIdentifier
- historyMessagesForSection?.append(blockSeparatorMessage)
- }
- if let lastSection = historySections.last,
- let lastHistoryMessages = historyDict[lastSection] {
- let lastHistoryMessageIP = IndexPath(row: lastHistoryMessages.count - 1, section: historySections.count - 1)
- // Merge last section of history messages with first section in current chat
- if let chatSection,
- let chatMessages = self.messages[chatSection] {
- if var historyMessagesForSection,
- let lastHistoryMessage = historyMessagesForSection.last,
- let firstChatMessage = chatMessages.first {
- firstChatMessage.isGroupMessage = self.shouldGroupMessage(newMessage: firstChatMessage, withMessage: lastHistoryMessage)
- historyMessagesForSection.append(contentsOf: chatMessages)
- self.messages[chatSection] = historyMessagesForSection
- }
- }
- return lastHistoryMessageIP
- }
- return nil
- }
- func insertMessages(messages: [NCChatMessage]) {
- for newMessage in messages {
- let newMessageDate = Date(timeIntervalSince1970: TimeInterval(newMessage.timestamp))
- if let keyDate = self.getKeyForDate(date: newMessageDate, inDictionary: self.messages),
- var messagesForDate = self.messages[keyDate] {
- for messageIndex in messagesForDate.indices {
- let currentMessage = messagesForDate[messageIndex]
- if currentMessage.timestamp > newMessage.timestamp {
- // Message inserted in between other messages
- if messageIndex > 0 {
- let previousMessage = messagesForDate[messageIndex - 1]
- newMessage.isGroupMessage = self.shouldGroupMessage(newMessage: newMessage, withMessage: previousMessage)
- }
- currentMessage.isGroupMessage = self.shouldGroupMessage(newMessage: currentMessage, withMessage: newMessage)
- messagesForDate.insert(newMessage, at: messageIndex)
- break
- } else if messageIndex == (messagesForDate.count - 1) {
- // Message inserted at the end of a date section
- newMessage.isGroupMessage = self.shouldGroupMessage(newMessage: newMessage, withMessage: currentMessage)
- messagesForDate.append(newMessage)
- break
- }
- }
- self.messages[keyDate] = messagesForDate
- } else {
- // We don't have messages for that date in our dictionary right now, so add this message as the first one
- self.messages[newMessageDate] = [newMessage]
- }
- }
- self.sortDateSections()
- }
- func appendMessages(messages: [NCChatMessage]) {
- // Because of the inout parameter, we can't call self.sortDateSections() inside the append function
- // Therefore we wrap it in this append function
- self.internalAppendMessages(messages: messages, inDictionary: &self.messages)
- self.sortDateSections()
- }
- private func internalAppendMessages(messages: [NCChatMessage], inDictionary dictionary: inout [Date: [NCChatMessage]]) {
- for newMessage in messages {
- let newMessageDate = Date(timeIntervalSince1970: TimeInterval(newMessage.timestamp))
- let keyDate = self.getKeyForDate(date: newMessageDate, inDictionary: dictionary)
- if let keyDate, let messagesForDate = dictionary[keyDate] {
- var messageUpdated = false
- // Check if we can update the message instead of adding a new one
- for messageIndex in messagesForDate.indices {
- let currentMessage = messagesForDate[messageIndex]
- if currentMessage.isSameMessage(newMessage) {
- // The newly received message either already exists or its temporary counterpart exists -> update
- // If the user type a command the newMessage.actorType will be "bots", then we should not group those messages
- // even if the original message was grouped.
- // Edited messages should not be grouped to make it clear, that the message was edited
- newMessage.isGroupMessage = currentMessage.isGroupMessage && newMessage.actorType != "bots" && newMessage.lastEditTimestamp == 0
- dictionary[keyDate]?[messageIndex] = newMessage
- messageUpdated = true
- break
- }
- }
- if !messageUpdated, let lastMessage = messagesForDate.last {
- newMessage.isGroupMessage = self.shouldGroupMessage(newMessage: newMessage, withMessage: lastMessage)
- dictionary[keyDate]?.append(newMessage)
- }
- } else {
- // Section not found, create new section and add message
- dictionary[newMessageDate] = [newMessage]
- }
- }
- }
- func removeMessage(at indexPath: IndexPath) {
- guard indexPath.section < self.dateSections.count else { return }
- let sectionKey = self.dateSections[indexPath.section]
- if var messages = self.messages[sectionKey], indexPath.row < messages.count {
- if messages.count == 1 {
- // Remove section
- self.messages.removeValue(forKey: sectionKey)
- self.sortDateSections()
- self.tableView?.beginUpdates()
- self.tableView?.deleteSections([indexPath.section], with: .none)
- self.tableView?.endUpdates()
- } else {
- // Remove message
- let isLastMessage = indexPath.row == (messages.count - 1)
- messages.remove(at: indexPath.row)
- self.messages[sectionKey] = messages
- self.tableView?.beginUpdates()
- self.tableView?.deleteRows(at: [indexPath], with: .none)
- self.tableView?.endUpdates()
- if !isLastMessage {
- // Update the message next to removed message
- let nextMessage = messages[indexPath.row]
- nextMessage.isGroupMessage = false
- if indexPath.row > 0 {
- let previousMessage = messages[indexPath.row - 1]
- nextMessage.isGroupMessage = self.shouldGroupMessage(newMessage: nextMessage, withMessage: previousMessage)
- }
- self.tableView?.beginUpdates()
- self.tableView?.reloadRows(at: [indexPath], with: .none)
- self.tableView?.endUpdates()
- }
- }
- }
- }
- func sortDateSections() {
- self.dateSections = self.messages.keys.sorted()
- }
- // MARK: - Message grouping
- func shouldGroupMessage(newMessage: NCChatMessage, withMessage lastMessage: NCChatMessage) -> Bool {
- let sameActor = newMessage.actorId == lastMessage.actorId
- let sameType = newMessage.isSystemMessage == lastMessage.isSystemMessage
- let timeDiff = (newMessage.timestamp - lastMessage.timestamp) < kChatMessageGroupTimeDifference
- let notEdited = newMessage.lastEditTimestamp == 0
- // Try to collapse system messages if the new message is not already collapsing some messages
- // Disable swiftlint -> not supported on Realm object
- // swiftlint:disable:next empty_count
- if newMessage.isSystemMessage, lastMessage.isSystemMessage, newMessage.collapsedMessages.count == 0 {
- self.tryToGroupSystemMessage(newMessage: newMessage, withMessage: lastMessage)
- }
- return sameActor && sameType && timeDiff && notEdited
- }
- func tryToGroupSystemMessage(newMessage: NCChatMessage, withMessage lastMessage: NCChatMessage) {
- if newMessage.systemMessage == lastMessage.systemMessage {
- if newMessage.actorId == lastMessage.actorId {
- // Same action and actor
- if ["user_added", "user_removed", "moderator_promoted", "moderator_demoted"].contains(newMessage.systemMessage) {
- self.collapseSystemMessage(newMessage, withMessage: lastMessage, withAction: newMessage.systemMessage)
- }
- } else {
- // Same action, different actor
- if ["call_joined", "call_left"].contains(newMessage.systemMessage) {
- self.collapseSystemMessage(newMessage, withMessage: lastMessage, withAction: newMessage.systemMessage)
- }
- }
- } else if newMessage.actorId == lastMessage.actorId {
- if lastMessage.systemMessage == "call_left", newMessage.systemMessage == "call_joined" {
- self.collapseSystemMessage(newMessage, withMessage: lastMessage, withAction: "call_reconnected")
- }
- }
- }
- // swiftlint:disable:next cyclomatic_complexity
- func collapseSystemMessage(_ newMessage: NCChatMessage, withMessage lastMessage: NCChatMessage, withAction action: String) {
- var collapseByMessage = lastMessage
- if let lastCollapsedByMessage = lastMessage.collapsedBy {
- collapseByMessage = lastCollapsedByMessage
- collapseByMessage.collapsedBy = nil
- self.tryToGroupSystemMessage(newMessage: newMessage, withMessage: collapseByMessage)
- return
- }
- newMessage.collapsedBy = collapseByMessage
- newMessage.isCollapsed = true
- collapseByMessage.collapsedMessages.add(newMessage.messageId as NSNumber)
- collapseByMessage.isCollapsed = true
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- var isUser0Self = false
- var isUser1Self = false
- if let userDict = collapseByMessage.messageParameters["user"] as? [String: Any] {
- isUser0Self = userDict["id"] as? String == activeAccount.userId && userDict["type"] as? String == "user"
- }
- if let userDict = newMessage.messageParameters["user"] as? [String: Any] {
- isUser1Self = userDict["id"] as? String == activeAccount.userId && userDict["type"] as? String == "user"
- }
- let isActor0Self = collapseByMessage.actorId == activeAccount.userId && collapseByMessage.actorType == "users"
- let isActor1Self = newMessage.actorId == activeAccount.userId && newMessage.actorType == "users"
- let isActor0Admin = collapseByMessage.actorId == "cli" && collapseByMessage.actorType == "guests"
- collapseByMessage.collapsedIncludesUserSelf = isUser0Self || isUser1Self
- collapseByMessage.collapsedIncludesActorSelf = isActor0Self || isActor1Self
- var collapsedMessageParameters: [String: Any] = [:]
- if let actor0Dict = collapseByMessage.messageParameters["actor"],
- let actor1Dict = newMessage.messageParameters["actor"] {
- collapsedMessageParameters["actor0"] = isActor0Self ? actor1Dict : actor0Dict
- collapsedMessageParameters["actor1"] = actor1Dict
- }
- if let user0Dict = collapseByMessage.messageParameters["user"],
- let user1Dict = newMessage.messageParameters["user"] {
- collapsedMessageParameters["user0"] = isUser0Self ? user1Dict : user0Dict
- collapsedMessageParameters["user1"] = user1Dict
- }
- collapseByMessage.setCollapsedMessageParameters(collapsedMessageParameters)
- if action == "user_added" {
- if isActor0Self {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You added {user0} and {user1}", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You added {user0} and %ld more participants", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- }
- } else if isActor0Admin {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator added you and {user0}", comment: "Please put {user0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator added {user0} and {user1}", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them"))
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator added you and %ld more participants", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator added {user0} and %ld more participants", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} added you and {user0}", comment: "Please put {actor0} and {user0} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} added {user0} and {user1}", comment: "Please put {actor0}, {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} added you and %ld more participants", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} added {user0} and %ld more participants", comment: "Please put {actor0}, {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- }
- } else if action == "user_removed" {
- if isActor0Self {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You removed {user0} and {user1}", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You removed {user0} and %ld more participants", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- }
- } else if isActor0Admin {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator removed you and {user0}", comment: "Please put {user0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator removed {user0} and {user1}", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator removed you and %ld more participants", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator removed {user0} and %ld more participants", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} removed you and {user0}", comment: "Please put {actor0} and {user0} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} removed {user0} and {user1}", comment: "Please put {actor0}, {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} removed you and %ld more participants", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} removed {user0} and %ld more participants", comment: "Please put {actor0}, {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- }
- } else if action == "moderator_promoted" {
- if isActor0Self {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You promoted {user0} and {user1} to moderators", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You promoted {user0} and %ld more participants to moderators", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- } else if isActor0Admin {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator promoted you and {user0} to moderators", comment: "Please put {user0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator promoted {user0} and {user1} to moderators", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator promoted you and %ld more participants to moderators", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator promoted {user0} and %ld more participants to moderators", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} promoted you and {user0} to moderators", comment: "Please put {actor0} and {user0} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} promoted {user0} and {user1} to moderators", comment: "Please put {actor0}, {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} promoted you and %ld more participants to moderators", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} promoted {user0} and %ld more participants to moderators", comment: "Please put {actor0}, {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- }
- } else if action == "moderator_demoted" {
- if isActor0Self {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You demoted {user0} and {user1} from moderators", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You demoted {user0} and %ld more participants from moderators", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- } else if isActor0Admin {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator demoted you and {user0} from moderators", comment: "Please put {user0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("An administrator demoted {user0} and {user1} from moderators", comment: "Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator demoted you and %ld more participants from moderators", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("An administrator demoted {user0} and %ld more participants from moderators", comment: "Please put {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} demoted you and {user0} from moderators", comment: "Please put {actor0} and {user0} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} demoted {user0} and {user1} from moderators", comment: "Please put {actor0}, {user0} and {user1} placeholders in the correct position on the translated text but do not translate them")
- }
- } else {
- if collapseByMessage.collapsedIncludesUserSelf {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} demoted you and %ld more participants from moderators", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} demoted {user0} and %ld more participants from moderators", comment: "Please put {actor0}, {user0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- }
- } else if action == "call_joined" {
- if collapseByMessage.collapsedIncludesActorSelf {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You and {actor0} joined the call", comment: "Please put {actor0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You and %ld more participants joined the call", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} and {actor1} joined the call", comment: "Please put {actor0} and {actor1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} and %ld more participants joined the call", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else if action == "call_left" {
- if collapseByMessage.collapsedIncludesActorSelf {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("You and {actor0} left the call", comment: "Please put {actor0} placeholder in the correct position on the translated text but do not translate it")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("You and %ld more participants left the call", comment: "Please put %ld placeholder in the correct position on the translated text but do not translate it"), collapseByMessage.collapsedMessages.count)
- }
- } else {
- if collapseByMessage.collapsedMessages.count == 1 {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} and {actor1} left the call", comment: "Please put {actor0} and {actor1} placeholders in the correct position on the translated text but do not translate them")
- } else {
- collapseByMessage.collapsedMessage = String(format: NSLocalizedString("{actor0} and %ld more participants left the call", comment: "Please put {actor0} and %ld placeholders in the correct position on the translated text but do not translate them"), collapseByMessage.collapsedMessages.count)
- }
- }
- } else if action == "call_reconnected" {
- if collapseByMessage.collapsedIncludesActorSelf {
- collapseByMessage.collapsedMessage = NSLocalizedString("You reconnected to the call", comment: "")
- } else {
- collapseByMessage.collapsedMessage = NSLocalizedString("{actor0} reconnected to the call", comment: "Please put {actor0} placeholder in the correct position on the translated text but do not translate it")
- }
- }
- }
- // MARK: - Reactions
- func addReaction(reaction: String, to message: NCChatMessage) {
- if message.reactionsArray().contains(where: {$0.reaction == reaction && $0.userReacted }) {
- // We can't add reaction twice
- return
- }
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- self.setTemporaryReaction(reaction: reaction, withState: .adding, toMessage: message)
- NCDatabaseManager.sharedInstance().increaseEmojiUsage(forEmoji: reaction, forAccount: activeAccount.accountId)
- NCAPIController.sharedInstance().addReaction(reaction, toMessage: message.messageId, inRoom: self.room.token, for: activeAccount) { _, error, _ in
- if error != nil {
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while adding a reaction to a message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- self.removeTemporaryReaction(reaction: reaction, forMessageId: message.messageId)
- }
- }
- }
- func removeReaction(reaction: String, from message: NCChatMessage) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- self.setTemporaryReaction(reaction: reaction, withState: .removing, toMessage: message)
- NCAPIController.sharedInstance().removeReaction(reaction, fromMessage: message.messageId, inRoom: self.room.token, for: activeAccount) { _, error, _ in
- if error != nil {
- NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while removing a reaction from a message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
- self.removeTemporaryReaction(reaction: reaction, forMessageId: message.messageId)
- }
- }
- }
- func addOrRemoveReaction(reaction: NCChatReaction, in message: NCChatMessage) {
- if message.isReactionBeingModified(reaction.reaction) {
- return
- }
- if reaction.userReacted {
- self.removeReaction(reaction: reaction.reaction, from: message)
- } else {
- self.addReaction(reaction: reaction.reaction, to: message)
- }
- }
- func removeTemporaryReaction(reaction: String, forMessageId messageId: Int) {
- DispatchQueue.main.async {
- guard let (indexPath, message) = self.indexPathAndMessage(forMessageId: messageId) else { return }
- message.removeReactionTemporarily(reaction)
- self.tableView?.beginUpdates()
- self.tableView?.reloadRows(at: [indexPath], with: .none)
- self.tableView?.endUpdates()
- }
- }
- func setTemporaryReaction(reaction: String, withState state: NCChatReactionState, toMessage message: NCChatMessage) {
- DispatchQueue.main.async {
- let isAtBottom = self.shouldScrollOnNewMessages()
- guard let (indexPath, message) = self.indexPathAndMessage(forMessageId: message.messageId) else { return }
- if state == .adding {
- message.addTemporaryReaction(reaction)
- } else if state == .removing {
- message.removeReactionTemporarily(reaction)
- }
- self.tableView?.performBatchUpdates({
- self.tableView?.reloadRows(at: [indexPath], with: .none)
- }, completion: { _ in
- if !isAtBottom {
- return
- }
- if let (indexPath, _) = self.getLastNonUpdateMessage() {
- self.tableView?.scrollToRow(at: indexPath, at: .bottom, animated: true)
- }
- })
- }
- }
- func showReactionsSummary(of message: NCChatMessage) {
- // Actuate `Peek` feedback (weak boom)
- AudioServicesPlaySystemSound(1519)
- let reactionsVC = ReactionsSummaryView(style: .insetGrouped)
- reactionsVC.room = self.room
- self.presentWithNavigation(reactionsVC, animated: true)
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().getReactions(nil, fromMessage: message.messageId, inRoom: self.room.token, for: activeAccount) { reactionsDict, error, _ in
- if error == nil,
- let reactions = reactionsDict as? [String: [[String: AnyObject]]] {
- reactionsVC.updateReactions(reactions: reactions)
- }
- }
- }
- // MARK: - UITableViewDataSource methods
- public override func numberOfSections(in tableView: UITableView) -> Int {
- if tableView != self.tableView {
- return super.numberOfSections(in: tableView)
- }
- // TODO: There should be a better place to do this
- if tableView == self.tableView, !self.dateSections.isEmpty {
- tableView.backgroundView = nil
- } else {
- tableView.backgroundView = self.chatBackgroundView
- }
- return self.dateSections.count
- }
- public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- if tableView != self.tableView {
- return super.tableView(tableView, numberOfRowsInSection: section)
- }
- let dateKey = self.dateSections[section]
- return self.messages[dateKey]?.count ?? 0
- }
- public override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
- if tableView != self.tableView {
- return super.tableView(tableView, titleForHeaderInSection: section)
- }
- let date = self.dateSections[section]
- return self.getHeaderString(fromDate: date)
- }
- public override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
- if tableView != self.tableView {
- return super.tableView(tableView, heightForHeaderInSection: section)
- }
- let date = self.dateSections[section]
- if let messages = self.messages[date], !messages.containsVisibleMessages() {
- return 0
- }
- return kDateHeaderViewHeight
- }
- public override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- if tableView != self.tableView {
- return super.tableView(tableView, viewForHeaderInSection: section)
- }
- let headerView = DateHeaderView()
- headerView.dateLabel.text = self.tableView(tableView, titleForHeaderInSection: section)
- headerView.dateLabel.layer.cornerRadius = 12
- headerView.dateLabel.clipsToBounds = true
- if let headerLabel = headerView.dateLabel as? DateLabelCustom {
- headerLabel.tableView = tableView
- }
- return headerView
- }
- public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
- guard tableView == self.tableView else { return }
- for indexPath in indexPaths {
- guard let message = self.message(for: indexPath) else { continue }
- DispatchQueue.global(qos: .userInitiated).async {
- guard message.messageId != kUnreadMessagesSeparatorIdentifier,
- message.messageId != kChatBlockSeparatorIdentifier
- else { return }
- if message.containsURL() {
- message.getReferenceData()
- }
- }
- }
- }
- public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- if tableView != self.autoCompletionView,
- let message = self.message(for: indexPath) {
- return self.getCell(for: message)
- }
- return super.tableView(tableView, cellForRowAt: indexPath)
- }
- // swiftlint:disable:next cyclomatic_complexity
- func getCell(for message: NCChatMessage) -> UITableViewCell {
- if message.messageId == kUnreadMessagesSeparatorIdentifier,
- let cell = self.tableView?.dequeueReusableCell(withIdentifier: MessageSeparatorCellIdentifier) as? MessageSeparatorTableViewCell {
- cell.messageId = message.messageId
- cell.separatorLabel.text = NSLocalizedString("Unread messages", comment: "")
- return cell
- }
- if message.messageId == kChatBlockSeparatorIdentifier,
- let cell = self.tableView?.dequeueReusableCell(withIdentifier: MessageSeparatorCellIdentifier) as? MessageSeparatorTableViewCell {
- cell.messageId = message.messageId
- cell.separatorLabel.text = NSLocalizedString("Some messages not shown, will be downloaded when online", comment: "")
- return cell
- }
- if message.isUpdateMessage,
- let cell = self.tableView?.dequeueReusableCell(withIdentifier: InvisibleSystemMessageCellIdentifier) as? SystemMessageTableViewCell {
- return cell
- }
- if message.isSystemMessage,
- let cell = self.tableView?.dequeueReusableCell(withIdentifier: SystemMessageCellIdentifier) as? SystemMessageTableViewCell {
- cell.delegate = self
- cell.setup(for: message)
- return cell
- }
- if message.isVoiceMessage {
- let cellIdentifier = message.isGroupMessage ? voiceGroupedMessageCellIdentifier : voiceMessageCellIdentifier
- if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell {
- cell.delegate = self
- cell.setup(for: message, withLastCommonReadMessage: self.room.lastCommonReadMessage)
- if let playerAudioFileStatus = self.playerAudioFileStatus,
- let voiceMessagesPlayer = self.voiceMessagesPlayer {
- if message.file().parameterId == playerAudioFileStatus.fileId, message.file().path == playerAudioFileStatus.filePath {
- cell.audioPlayerView?.setPlayerProgress(voiceMessagesPlayer.currentTime, isPlaying: voiceMessagesPlayer.isPlaying, maximumValue: voiceMessagesPlayer.duration)
- } else {
- cell.audioPlayerView?.resetPlayer()
- }
- } else {
- cell.audioPlayerView?.resetPlayer()
- }
- return cell
- }
- }
- if message.file() != nil {
- let cellIdentifier = message.isGroupMessage ? fileGroupedMessageCellIdentifier : fileMessageCellIdentifier
- if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell {
- cell.delegate = self
- cell.setup(for: message, withLastCommonReadMessage: self.room.lastCommonReadMessage)
- return cell
- }
- }
- if message.geoLocation() != nil {
- let cellIdentifier = message.isGroupMessage ? locationGroupedMessageCellIdentifier : locationMessageCellIdentifier
- if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell {
- cell.delegate = self
- cell.setup(for: message, withLastCommonReadMessage: self.room.lastCommonReadMessage)
- return cell
- }
- }
- if message.poll != nil {
- let cellIdentifier = message.isGroupMessage ? pollGroupedMessageCellIdentifier : pollMessageCellIdentifier
- if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell {
- cell.delegate = self
- cell.setup(for: message, withLastCommonReadMessage: self.room.lastCommonReadMessage)
- return cell
- }
- }
- var cellIdentifier = chatMessageCellIdentifier
- if message.isGroupMessage {
- cellIdentifier = chatGroupedMessageCellIdentifier
- } else if message.parent != nil {
- cellIdentifier = chatReplyMessageCellIdentifier
- }
- if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell {
- cell.delegate = self
- cell.setup(for: message, withLastCommonReadMessage: self.room.lastCommonReadMessage)
- return cell
- }
- return UITableViewCell()
- }
- public override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
- if tableView == self.autoCompletionView {
- return super.tableView(tableView, heightForRowAt: indexPath)
- }
- if let message = self.message(for: indexPath) {
- return self.getCellHeight(for: message)
- }
- return chatMessageCellMinimumHeight
- }
- func getCellHeight(for message: NCChatMessage) -> CGFloat {
- guard let tableView = self.tableView else { return chatMessageCellMinimumHeight }
- var width = tableView.frame.width - kChatCellAvatarHeight
- width -= tableView.safeAreaInsets.left + tableView.safeAreaInsets.right
- return self.getCellHeight(for: message, with: width)
- }
- lazy var textViewForSizing: UITextView = {
- return MessageBodyTextView()
- }()
- // swiftlint:disable:next cyclomatic_complexity
- func getCellHeight(for message: NCChatMessage, with originalWidth: CGFloat) -> CGFloat {
- // Chat separators
- if message.messageId == kUnreadMessagesSeparatorIdentifier ||
- message.messageId == kChatBlockSeparatorIdentifier {
- return kMessageSeparatorCellHeight
- }
- // Update messages (the ones that notify about an update in one message, they should not be displayed)
- if message.message.isEmpty || message.isUpdateMessage || (message.isCollapsed && message.collapsedBy != nil) {
- return 0.0
- }
- // Chat messages
- let messageString = message.parsedMarkdownForChat() ?? NSMutableAttributedString()
- var width = originalWidth
- width -= message.isSystemMessage ? 80.0 : 30.0 // *right(10) + dateLabel(40) : 3*right(10)
- self.textViewForSizing.attributedText = messageString
- let bodyBounds = self.textViewForSizing.sizeThatFits(CGSize(width: width, height: CGFLOAT_MAX))
- var height = ceil(bodyBounds.height)
- if message.poll != nil {
- height = PollMessageView().pollMessageBodyHeight(with: messageString.string, width: width)
- }
- if (message.isGroupMessage && message.parent == nil) || message.isSystemMessage {
- height += 10 // 2*left(5)
- if height < chatGroupedMessageCellMinimumHeight {
- height = chatGroupedMessageCellMinimumHeight
- }
- } else {
- height += kChatCellAvatarHeight
- height += 20.0 // right(10) + 2*left(5)
- if height < chatMessageCellMinimumHeight {
- height = chatMessageCellMinimumHeight
- }
- }
- if !message.reactionsArray().isEmpty {
- height += 40 // reactionsView(40)
- }
- if message.containsURL() {
- height += 105
- }
- if message.parent != nil {
- height += 60 // quoteView(60)
- }
- // Voice message should be before message.file check since it contains a file
- if message.isVoiceMessage {
- height -= ceil(bodyBounds.height)
- height += voiceMessageCellPlayerHeight
- } else if let file = message.file() {
- if file.previewImageHeight > 0 {
- height += CGFloat(file.previewImageHeight)
- } else if case let estimatedHeight = BaseChatTableViewCell.getEstimatedPreviewSize(for: message), estimatedHeight > 0 {
- height += estimatedHeight
- message.setPreviewImageHeight(estimatedHeight)
- } else {
- height += fileMessageCellFileMaxPreviewHeight
- }
- height += 10 // right(10)
- // if the message is a media file, reduce the message height by the bodyTextView height to hide it since it usually just contains an autogenerated file name (e.g. IMG_1234.jpg)
- if NCUtils.isImage(fileType: file.mimetype) || NCUtils.isVideo(fileType: file.mimetype) {
- // Only hide the filename if there's a preview available and we didn't receive a file caption
- if file.previewAvailable && message.message == "{file}" {
- height -= ceil(bodyBounds.height)
- }
- }
- }
- if message.geoLocation() != nil {
- height += locationMessageCellPreviewHeight + 10 // right(10)
- }
- return height
- }
- public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- if tableView == self.tableView {
- self.emojiTextField.resignFirstResponder()
- self.datePickerTextField.resignFirstResponder()
- // Disable swiftlint -> not supported on a Realm object
- // swiftlint:disable:next empty_count
- if let message = self.message(for: indexPath), message.collapsedMessages.count > 0 {
- self.cellWantsToCollapseMessages(with: message)
- }
- tableView.deselectRow(at: indexPath, animated: true)
- } else {
- super.tableView(tableView, didSelectRowAt: indexPath)
- }
- }
- // MARK: - ContextMenu (Long press on message)
- public override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
- if tableView == self.autoCompletionView {
- return nil
- }
- let cell = tableView.cellForRow(at: indexPath)
- // Show reactionSummary for legacy cells
- if let cell = cell as? ChatTableViewCell {
- let pointInCell = tableView.convert(point, to: cell)
- let reactionView = cell.contentView.subviews.first(where: { $0 is ReactionsView && $0.frame.contains(pointInCell) })
- if reactionView != nil {
- self.showReactionsSummary(of: cell.message)
- return nil
- }
- }
- if let cell = cell as? BaseChatTableViewCell {
- let pointInCell = tableView.convert(point, to: cell)
- let pointInReactionPart = cell.convert(pointInCell, to: cell.reactionPart)
- let reactionView = cell.reactionPart.subviews.first(where: { $0 is ReactionsView && $0.frame.contains(pointInReactionPart) })
- if reactionView != nil, let message = cell.message {
- self.showReactionsSummary(of: message)
- return nil
- }
- }
- guard let message = self.message(for: indexPath) else { return nil }
- if message.isSystemMessage || message.isDeletedMessage || message.messageId == kUnreadMessagesSeparatorIdentifier {
- return nil
- }
- var actions: [UIMenuElement] = []
- // Copy option
- actions.append(UIAction(title: NSLocalizedString("Copy", comment: ""), image: .init(systemName: "square.on.square")) { _ in
- self.didPressCopy(for: message)
- })
- // Copy Link
- actions.append(UIAction(title: NSLocalizedString("Copy message link", comment: ""), image: .init(systemName: "link")) { _ in
- self.didPressCopyLink(for: message)
- })
- let menu = UIMenu(children: actions)
- let configuration = UIContextMenuConfiguration(identifier: indexPath as NSIndexPath) {
- return nil
- } actionProvider: { _ in
- return menu
- }
- return configuration
- }
- public override func tableView(_ tableView: UITableView, willDisplayContextMenu configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
- animator?.addAnimations {
- // Only set these, when the context menu is fully visible
- self.contextMenuAccessoryView?.alpha = 1
- self.contextMenuMessageView?.layer.cornerRadius = 10
- self.contextMenuMessageView?.layer.mask = nil
- }
- }
- public override func tableView(_ tableView: UITableView, willEndContextMenuInteraction configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
- animator?.addCompletion {
- // Wait until the context menu is completely hidden before we execute any method
- if let contextMenuActionBlock = self.contextMenuActionBlock {
- contextMenuActionBlock()
- self.contextMenuActionBlock = nil
- }
- }
- }
- internal func getContextMenuAccessoryView(forMessage message: NCChatMessage, forIndexPath indexPath: IndexPath, withCellHeight cellHeight: CGFloat) -> UIView? {
- // We don't provide a accessory view in the BaseChatViewController, but can add it in a subclass
- return nil
- }
- public override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
- guard let indexPath = configuration.identifier as? NSIndexPath,
- let message = self.message(for: indexPath as IndexPath)
- else { return nil }
- let maxPreviewWidth = self.view.bounds.size.width - self.view.safeAreaInsets.left - self.view.safeAreaInsets.right
- let maxPreviewHeight = self.view.bounds.size.height * 0.6
- // TODO: Take padding into account
- let maxTextWidth = maxPreviewWidth - kChatCellAvatarHeight
- // We need to get the height of the original cell to center the preview correctly (as the preview is always non-grouped)
- let heightOfOriginalCell = self.getCellHeight(for: message, with: maxTextWidth)
- // Remember grouped-status -> Create a previewView which always is a non-grouped-message
- let isGroupMessage = message.isGroupMessage
- message.isGroupMessage = false
- let previewTableViewCell = self.getCell(for: message)
- var cellHeight = self.getCellHeight(for: message, with: maxTextWidth)
- // Cut the height if bigger than max height
- if cellHeight > maxPreviewHeight {
- cellHeight = maxPreviewHeight
- }
- // Use the contentView of the UITableViewCell as a preview view
- let previewMessageView = previewTableViewCell.contentView
- previewMessageView.frame = CGRect(x: 0, y: 0, width: maxPreviewWidth, height: cellHeight)
- previewMessageView.layer.masksToBounds = true
- // Create a mask to not show the avatar part when showing a grouped messages while animating
- // The mask will be reset in willDisplayContextMenuWithConfiguration so the avatar is visible when the context menu is shown
- let maskLayer = CAShapeLayer()
- let maskRect = CGRect(x: 0, y: previewMessageView.frame.size.height - heightOfOriginalCell, width: previewMessageView.frame.size.width, height: heightOfOriginalCell)
- maskLayer.path = CGPath(rect: maskRect, transform: nil)
- previewMessageView.layer.mask = maskLayer
- previewMessageView.backgroundColor = .systemBackground
- self.contextMenuMessageView = previewMessageView
- // Restore grouped-status
- message.isGroupMessage = isGroupMessage
- var containerView: UIView
- var cellCenter = CGPoint()
- if let accessoryView = self.getContextMenuAccessoryView(forMessage: message, forIndexPath: indexPath as IndexPath, withCellHeight: cellHeight) {
- self.contextMenuAccessoryView = accessoryView
- // maxY = height + y
- let totalAccessoryFrameHeight = accessoryView.frame.maxY - cellHeight
- containerView = UIView(frame: .init(x: 0, y: 0, width: Int(maxPreviewWidth), height: Int(cellHeight + totalAccessoryFrameHeight)))
- containerView.backgroundColor = .clear
- containerView.addSubview(previewMessageView)
- containerView.addSubview(accessoryView)
- if let cell = tableView.cellForRow(at: indexPath as IndexPath) {
- // On large iPhones (with regular landscape size, like iPhone X) we need to take the safe area into account when calculating the center
- let cellCenterX = cell.center.x + self.view.safeAreaInsets.left / 2 - self.view.safeAreaInsets.right / 2
- let cellCenterY = cell.center.y + (totalAccessoryFrameHeight) / 2 - (cellHeight - heightOfOriginalCell) / 2
- cellCenter = CGPoint(x: cellCenterX, y: cellCenterY)
- }
- } else {
- containerView = UIView(frame: .init(x: 0, y: 0, width: maxPreviewWidth, height: cellHeight))
- containerView.backgroundColor = .clear
- containerView.addSubview(previewMessageView)
- if let cell = tableView.cellForRow(at: indexPath as IndexPath) {
- // On large iPhones (with regular landscape size, like iPhone X) we need to take the safe area into account when calculating the center
- let cellCenterX = cell.center.x + self.view.safeAreaInsets.left / 2 - self.view.safeAreaInsets.right / 2
- let cellCenterY = cell.center.y - (cellHeight - heightOfOriginalCell) / 2
- cellCenter = CGPoint(x: cellCenterX, y: cellCenterY)
- }
- }
- // Create a preview target which allows us to have a transparent background
- let previewTarget = UIPreviewTarget(container: tableView, center: cellCenter)
- let previewParameter = UIPreviewParameters()
- // Remove the background and the drop shadow from our custom preview view
- previewParameter.backgroundColor = .clear
- previewParameter.shadowPath = UIBezierPath()
- return UITargetedPreview(view: containerView, parameters: previewParameter, target: previewTarget)
- }
- // MARK: - Chat functions
- public func showLoadingHistoryView() {
- self.loadingHistoryView = UIActivityIndicatorView(frame: .init(x: 0, y: 0, width: 30, height: 30))
- self.loadingHistoryView?.color = .darkGray
- self.loadingHistoryView?.startAnimating()
- self.tableView?.tableHeaderView = self.loadingHistoryView
- }
- func hideLoadingHistoryView() {
- self.loadingHistoryView = nil
- self.tableView?.tableHeaderView = nil
- }
- func shouldScrollOnNewMessages() -> Bool {
- guard self.isVisible, let tableView = self.tableView else { return false }
- // Scroll if table view is at the bottom (or 80px up)
- let minimumOffset = (tableView.contentSize.height - tableView.frame.size.height) - 80
- if tableView.contentOffset.y >= minimumOffset {
- return true
- }
- return false
- }
- public func cleanChat() {
- self.messages = [:]
- self.dateSections = []
- self.hideNewMessagesView()
- self.tableView?.reloadData()
- }
- public func savePendingMessage() {
- if self.textInputbar.isEditing {
- // We don't want to save a message that we are editing
- return
- }
- self.room.pendingMessage = self.textView.text
- NCRoomsManager.sharedInstance().updatePendingMessage(self.room.pendingMessage, for: self.room)
- }
- public func clearPendingMessage() {
- self.room.pendingMessage = ""
- NCRoomsManager.sharedInstance().updatePendingMessage("", for: self.room)
- }
- private func getKeyForDate(date: Date, inDictionary dict: [Date: [NCChatMessage]]) -> Date? {
- let currentCalendar = NSCalendar.current
- return dict.first(where: { currentCalendar.isDate(date, inSameDayAs: $0.key) })?.key
- }
- internal func message(for indexPath: IndexPath) -> NCChatMessage? {
- let sectionDate = self.dateSections[indexPath.section]
- if let message = self.messages[sectionDate]?[indexPath.row] {
- return message
- }
- return nil
- }
- internal func indexPath(for message: NCChatMessage) -> IndexPath? {
- let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp))
- guard let keyDate = self.getKeyForDate(date: messageDate, inDictionary: self.messages),
- let dateSection = dateSections.firstIndex(of: keyDate),
- let messages = messages[keyDate]
- else { return nil }
- for i in messages.indices {
- let chatMessage = messages[i]
- if chatMessage.isSameMessage(message) {
- return IndexPath(row: i, section: dateSection)
- }
- }
- return nil
- }
- /// Iterate through all messages starting with the first message and returns the first message that fulfills the predicate
- private func indexPathAndMessageFromStart(with predicate: (NCChatMessage) -> Bool) -> (indexPath: IndexPath, message: NCChatMessage)? {
- for sectionIndex in dateSections.indices {
- let section = dateSections[sectionIndex]
- guard let messages = messages[section] else { continue }
- for messageIndex in messages.indices {
- let message = messages[messageIndex]
- if predicate(message) {
- return (IndexPath(row: messageIndex, section: sectionIndex), message)
- }
- }
- }
- return nil
- }
- /// Iterate through all messages starting with the last message and returns the first message that fulfills the predicate
- private func indexPathAndMessageFromEnd(with predicate: (NCChatMessage) -> Bool) -> (indexPath: IndexPath, message: NCChatMessage)? {
- for sectionIndex in dateSections.indices.reversed() {
- let section = dateSections[sectionIndex]
- guard let messages = messages[section] else { continue }
- for messageIndex in messages.indices.reversed() {
- let message = messages[messageIndex]
- if predicate(message) {
- return (IndexPath(row: messageIndex, section: sectionIndex), message)
- }
- }
- }
- return nil
- }
- internal func indexPathAndMessage(forMessageId messageId: Int) -> (indexPath: IndexPath, message: NCChatMessage)? {
- return self.indexPathAndMessageFromEnd(with: { $0.messageId == messageId })
- }
- internal func indexPathAndMessage(forReferenceId referenceId: String) -> (indexPath: IndexPath, message: NCChatMessage)? {
- return self.indexPathAndMessageFromEnd(with: { $0.referenceId == referenceId })
- }
- internal func indexPathForUnreadMessageSeparator() -> IndexPath? {
- return self.indexPathAndMessageFromEnd(with: { $0.messageId == kUnreadMessagesSeparatorIdentifier })?.indexPath
- }
- internal func getLastNonUpdateMessage() -> (indexPath: IndexPath, message: NCChatMessage)? {
- return self.indexPathAndMessageFromEnd(with: { !$0.isUpdateMessage })
- }
- internal func getLastRealMessage() -> (indexPath: IndexPath, message: NCChatMessage)? {
- // Ignore temporary messages
- return self.indexPathAndMessageFromEnd(with: { $0.messageId > 0 })
- }
- internal func getFirstRealMessage() -> (indexPath: IndexPath, message: NCChatMessage)? {
- // Ignore temporary messages
- return self.indexPathAndMessageFromStart(with: { $0.messageId > 0 })
- }
- internal func indexPathForLastMessage() -> IndexPath? {
- return self.indexPathAndMessageFromEnd(with: { _ in return true })?.indexPath
- }
- internal func removeUnreadMessagesSeparator() {
- if let indexPath = self.indexPathForUnreadMessageSeparator() {
- let separatorDate = self.dateSections[indexPath.section]
- self.messages[separatorDate]?.remove(at: indexPath.row)
- self.tableView?.deleteRows(at: [indexPath], with: .fade)
- }
- }
- internal func checkUnreadMessagesVisibility() {
- DispatchQueue.main.async {
- if let firstUnreadMessage = self.firstUnreadMessage,
- let indexPath = self.indexPath(for: firstUnreadMessage) {
- if self.tableView?.indexPathsForVisibleRows?.contains(indexPath) ?? false {
- self.hideNewMessagesView()
- }
- }
- }
- }
- internal func highlightMessage(at indexPath: IndexPath, with scrollPosition: UITableView.ScrollPosition) {
- self.tableView?.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
- DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
- self.tableView?.deselectRow(at: indexPath, animated: true)
- }
- }
- internal func highlightMessageWithContentOffset(messageId: Int) {
- guard messageId > 0,
- let tableView = self.tableView,
- let (indexPath, _) = self.indexPathAndMessage(forMessageId: messageId)
- else { return }
- self.highlightMessage(at: indexPath, with: .none)
- let rect = tableView.rectForRow(at: indexPath)
- // ContentOffset when the cell is at the top of the tableView
- let contentOffsetTop = rect.origin.y - tableView.safeAreaInsets.top
- // ContentOffset when the cell is at the middle of the tableView
- let contentOffsetMiddle = contentOffsetTop - tableView.frame.height / 2 + rect.height / 2
- // Fallback to the top offset in case the top of the cell would be scrolled outside of the view
- let newContentOffset = min(contentOffsetTop, contentOffsetMiddle)
- tableView.contentOffset.y = newContentOffset
- }
- public func reloadDataAndHighlightMessage(messageId: Int) {
- self.tableView?.reloadData()
- self.highlightMessageWithContentOffset(messageId: messageId)
- }
- func showNewMessagesView(until message: NCChatMessage) {
- self.firstUnreadMessage = message
- self.unreadMessageButton.isHidden = false
- // Check if unread messages are already visible
- self.checkUnreadMessagesVisibility()
- }
- func hideNewMessagesView() {
- self.firstUnreadMessage = nil
- self.unreadMessageButton.isHidden = true
- }
- // MARK: - FileMessageTableViewCellDelegate
- public func cellWants(toDownloadFile fileParameter: NCMessageFileParameter, for message: NCChatMessage) {
- if NCUtils.isImage(fileType: fileParameter.mimetype) {
- let mediaViewController = NCMediaViewerViewController(initialMessage: message)
- let navController = CustomPresentableNavigationController(rootViewController: mediaViewController)
- self.present(navController, interactiveDismissalType: .standard)
- return
- }
- if fileParameter.fileStatus != nil && fileParameter.fileStatus?.isDownloading ?? false {
- print("File already downloading -> skipping new download")
- return
- }
- let downloader = NCChatFileController()
- downloader.delegate = self
- downloader.downloadFile(fromMessage: fileParameter)
- }
- public func cellHasDownloadedImagePreview(withHeight height: CGFloat, for message: NCChatMessage) {
- if message.file().previewImageHeight == Int(height) {
- return
- }
- let isAtBottom = self.shouldScrollOnNewMessages()
- message.setPreviewImageHeight(height)
- CATransaction.begin()
- CATransaction.setCompletionBlock {
- DispatchQueue.main.async {
- // make sure we're really at the bottom after updating a message since the file previews could grow in size if they contain a media file preview, thus giving the effect of not being at the bottom of the chat
- if isAtBottom, !(self.tableView?.isDecelerating ?? false) {
- self.tableView?.slk_scrollToBottom(animated: true)
- self.updateToolbar(animated: true)
- }
- }
- }
- self.tableView?.beginUpdates()
- self.tableView?.endUpdates()
- CATransaction.commit()
- }
- // MARK: - VoiceMessageTableViewCellDelegate
- public func cellWants(toPlayAudioFile fileParameter: NCMessageFileParameter) {
- if fileParameter.fileStatus != nil && fileParameter.fileStatus?.isDownloading ?? false {
- print("File already downloading -> skipping new download")
- return
- }
- if let voiceMessagesPlayer = self.voiceMessagesPlayer,
- let playerAudioFileStatus = self.playerAudioFileStatus,
- !voiceMessagesPlayer.isPlaying,
- fileParameter.parameterId == playerAudioFileStatus.fileId,
- fileParameter.path == playerAudioFileStatus.filePath {
- self.playVoiceMessagePlayer()
- return
- }
- let downloader = NCChatFileController()
- downloader.delegate = self
- downloader.messageType = kMessageTypeVoiceMessage
- downloader.downloadFile(fromMessage: fileParameter)
- }
- public func cellWants(toPauseAudioFile fileParameter: NCMessageFileParameter) {
- if let voiceMessagesPlayer = self.voiceMessagesPlayer,
- let playerAudioFileStatus = self.playerAudioFileStatus,
- voiceMessagesPlayer.isPlaying,
- fileParameter.parameterId == playerAudioFileStatus.fileId,
- fileParameter.path == playerAudioFileStatus.filePath {
- self.pauseVoiceMessagePlayer()
- }
- }
- public func cellWants(toChangeProgress progress: CGFloat, fromAudioFile fileParameter: NCMessageFileParameter) {
- if let playerAudioFileStatus = self.playerAudioFileStatus,
- fileParameter.parameterId == playerAudioFileStatus.fileId,
- fileParameter.path == playerAudioFileStatus.filePath {
- self.pauseVoiceMessagePlayer()
- self.voiceMessagesPlayer?.currentTime = progress
- self.checkVisibleCellAudioPlayers()
- }
- }
- // MARK: - LocationMessageTableViewCell
- public func cellWants(toOpenLocation geoLocationRichObject: GeoLocationRichObject) {
- self.presentWithNavigation(MapViewController(geoLocationRichObject: geoLocationRichObject), animated: true)
- }
- // MARK: - ObjectShareMessageTableViewCell
- public func cellWants(toOpenPoll poll: NCMessageParameter) {
- let pollVC = PollVotingView(style: .insetGrouped)
- pollVC.room = self.room
- self.presentWithNavigation(pollVC, animated: true)
- if let pollId = Int(poll.parameterId) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().getPollWithId(pollId, inRoom: self.room.token, for: activeAccount) { poll, error, _ in
- if error == nil, let poll {
- pollVC.updatePoll(poll: poll)
- }
- }
- }
- }
- // MARK: - PollCreationViewControllerDelegate
- func pollCreationViewControllerWantsToCreatePoll(pollCreationViewController: PollCreationViewController, question: String, options: [String], resultMode: NCPollResultMode, maxVotes: Int) {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- NCAPIController.sharedInstance().createPoll(withQuestion: question, options: options, resultMode: resultMode, maxVotes: maxVotes, inRoom: self.room.token, for: activeAccount) { _, error, _ in
- if error != nil {
- pollCreationViewController.showCreationError()
- } else {
- pollCreationViewController.close()
- }
- }
- }
- // MARK: - SystemMessageTableViewCellDelegate
- public func cellWantsToCollapseMessages(with message: NCChatMessage!) {
- DispatchQueue.main.async {
- guard let messageIds = message.collapsedMessages.value(forKey: "self") as? [NSNumber] else { return }
- let collapse = !message.isCollapsed
- var reloadIndexPath: [IndexPath] = []
- if let indexPath = self.indexPath(for: message) {
- reloadIndexPath.append(indexPath)
- message.isCollapsed = collapse
- }
- for messageId in messageIds {
- if let (indexPath, message) = self.indexPathAndMessage(forMessageId: messageId.intValue) {
- reloadIndexPath.append(indexPath)
- message.isCollapsed = collapse
- }
- }
- self.tableView?.beginUpdates()
- self.tableView?.reloadRows(at: reloadIndexPath, with: .automatic)
- self.tableView?.endUpdates()
- }
- }
- // MARK: - ChatMessageTableViewCellDelegate
- public func cellWantsToScroll(to message: NCChatMessage) {
- DispatchQueue.main.async {
- if let indexPath = self.indexPath(for: message) {
- self.highlightMessage(at: indexPath, with: .top)
- }
- }
- }
- public func cellDidSelectedReaction(_ reaction: NCChatReaction!, for message: NCChatMessage) {
- // Do nothing -> override in subclass
- }
- public func cellWantsToReply(to message: NCChatMessage) {
- if self.textInputbar.isEditing {
- return
- }
- self.didPressReply(for: message)
- }
- // MARK: - NCChatFileControllerDelegate
- public func fileControllerDidLoadFile(_ fileController: NCChatFileController, with fileStatus: NCChatFileStatus) {
- if fileController.messageType == kMessageTypeVoiceMessage {
- if fileController.actionType == actionTypeTranscribeVoiceMessage {
- self.transcribeVoiceMessage(with: fileStatus)
- } else {
- self.setupVoiceMessagePlayer(with: fileStatus)
- }
- return
- }
- if self.isPreviewControllerShown {
- // We are showing a file already, no need to open another one
- return
- }
- guard let tableView = self.tableView else { return }
- var isFileCellStillVisible = false
- if let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows {
- for indexPath in indexPathsForVisibleRows {
- guard let message = self.message(for: indexPath), let file = message.file() else { continue }
- if file.parameterId == fileStatus.fileId && file.path == fileStatus.filePath {
- isFileCellStillVisible = true
- break
- }
- }
- }
- if !isFileCellStillVisible {
- // Only open file when the corresponding cell is still visible on the screen
- return
- }
- DispatchQueue.main.async {
- self.isPreviewControllerShown = true
- self.previewControllerFilePath = fileStatus.fileLocalPath
- // When the keyboard is not dismissed, dismissing the previewController might result in a corrupted keyboardView
- self.dismissKeyboard(false)
- guard let fileLocalPath = fileStatus.fileLocalPath else { return }
- let fileExtension = URL(fileURLWithPath: fileLocalPath).pathExtension.lowercased()
- // Use VLCKitVideoViewController for file formats unsupported by the native PreviewController
- if VLCKitVideoViewController.supportedFileExtensions.contains(fileExtension) {
- let vlcKitViewController = VLCKitVideoViewController(filePath: fileLocalPath)
- vlcKitViewController.delegate = self
- vlcKitViewController.modalPresentationStyle = .fullScreen
- self.present(vlcKitViewController, animated: true)
- return
- }
- let preview = QLPreviewController()
- preview.dataSource = self
- preview.delegate = self
- preview.navigationController?.navigationBar.tintColor = NCAppBranding.themeTextColor()
- preview.navigationController?.navigationBar.barTintColor = NCAppBranding.themeColor()
- preview.tabBarController?.tabBar.tintColor = NCAppBranding.themeColor()
- let appearance = UINavigationBarAppearance()
- appearance.configureWithOpaqueBackground()
- appearance.titleTextAttributes = [.foregroundColor: NCAppBranding.themeTextColor()]
- appearance.backgroundColor = NCAppBranding.themeColor()
- self.navigationItem.standardAppearance = appearance
- self.navigationItem.compactAppearance = appearance
- self.navigationItem.scrollEdgeAppearance = appearance
- self.present(preview, animated: true)
- }
- }
- public func fileControllerDidFailLoadingFile(_ fileController: NCChatFileController, withErrorDescription errorDescription: String) {
- let alert = UIAlertController(title: NSLocalizedString("Unable to load file", comment: ""),
- message: errorDescription,
- preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default))
- NCUserInterfaceController.sharedInstance().presentAlertViewController(alert)
- }
- // MARK: - QLPreviewControllerDelegate/DataSource
- public func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
- return 1
- }
- public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
- if let filePath = self.previewControllerFilePath {
- return URL(fileURLWithPath: filePath) as QLPreviewItem
- }
- return URL(fileURLWithPath: "") as QLPreviewItem
- }
- public func previewControllerDidDismiss(_ controller: QLPreviewController) {
- self.isPreviewControllerShown = false
- }
- // MARK: - VLCVideoViewControllerDelegate
- func vlckitVideoViewControllerDismissed(_ controller: VLCKitVideoViewController) {
- self.isPreviewControllerShown = false
- }
- }
- extension Sequence where Iterator.Element == NCChatMessage {
- func containsUserMessage() -> Bool {
- let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
- return self.contains(where: { !$0.isSystemMessage && $0.actorId == activeAccount.userId })
- }
- func containsVisibleMessages() -> Bool {
- return self.contains(where: { !$0.isUpdateMessage })
- }
- }
|