BaseChatTableViewCell+File.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //
  2. // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. extension BaseChatTableViewCell {
  6. func setupForFileCell(with message: NCChatMessage, with account: TalkAccount) {
  7. if self.filePreviewImageView == nil {
  8. // Preview image view
  9. let filePreviewImageView = FilePreviewImageView(frame: .init(x: 0, y: 0, width: fileMessageCellFileMaxPreviewHeight, height: fileMessageCellFileMaxPreviewWidth))
  10. self.filePreviewImageView = filePreviewImageView
  11. filePreviewImageView.translatesAutoresizingMaskIntoConstraints = false
  12. filePreviewImageView.layer.cornerRadius = chatMessageCellPreviewCornerRadius
  13. filePreviewImageView.layer.masksToBounds = true
  14. filePreviewImageView.contentMode = .scaleAspectFit
  15. self.messageBodyView.addSubview(filePreviewImageView)
  16. let previewTap = UITapGestureRecognizer(target: self, action: #selector(filePreviewTapped))
  17. filePreviewImageView.addGestureRecognizer(previewTap)
  18. filePreviewImageView.isUserInteractionEnabled = true
  19. // PlayIcon for video files with preview
  20. let filePreviewPlayIconImageView = UIImageView(frame: .init(x: 0, y: 0, width: fileMessageCellFileMaxPreviewHeight, height: fileMessageCellFileMaxPreviewWidth))
  21. self.filePreviewPlayIconImageView = filePreviewPlayIconImageView
  22. filePreviewPlayIconImageView.isHidden = true
  23. filePreviewPlayIconImageView.tintColor = .init(white: 1.0, alpha: 0.8)
  24. filePreviewPlayIconImageView.image = .init(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(weight: .black))
  25. filePreviewImageView.addSubview(filePreviewPlayIconImageView)
  26. filePreviewImageView.bringSubviewToFront(filePreviewPlayIconImageView)
  27. // Activity indicator while loading previews
  28. let filePreviewActivityIndicator = MDCActivityIndicator(frame: .init(x: 0, y: 0, width: fileMessageCellMinimumHeight, height: fileMessageCellMinimumHeight))
  29. self.filePreviewActivityIndicator = filePreviewActivityIndicator
  30. filePreviewActivityIndicator.translatesAutoresizingMaskIntoConstraints = false
  31. filePreviewActivityIndicator.radius = fileMessageCellMinimumHeight / 2
  32. filePreviewActivityIndicator.cycleColors = [.systemGray2]
  33. filePreviewActivityIndicator.indicatorMode = .indeterminate
  34. filePreviewImageView.addSubview(filePreviewActivityIndicator)
  35. NSLayoutConstraint.activate([
  36. filePreviewActivityIndicator.centerYAnchor.constraint(equalTo: filePreviewImageView.centerYAnchor),
  37. filePreviewActivityIndicator.centerXAnchor.constraint(equalTo: filePreviewImageView.centerXAnchor)
  38. ])
  39. // Add everything to messageBodyView
  40. let heightConstraint = filePreviewImageView.heightAnchor.constraint(equalToConstant: fileMessageCellFileMaxPreviewHeight)
  41. let widthConstraint = filePreviewImageView.widthAnchor.constraint(equalToConstant: fileMessageCellFileMaxPreviewWidth)
  42. self.filePreviewImageViewHeightConstraint = heightConstraint
  43. self.filePreviewImageViewWidthConstraint = widthConstraint
  44. let messageTextView = MessageBodyTextView()
  45. self.messageTextView = messageTextView
  46. messageTextView.translatesAutoresizingMaskIntoConstraints = false
  47. self.messageBodyView.addSubview(messageTextView)
  48. NSLayoutConstraint.activate([
  49. filePreviewImageView.leftAnchor.constraint(equalTo: self.messageBodyView.leftAnchor),
  50. filePreviewImageView.topAnchor.constraint(equalTo: self.messageBodyView.topAnchor),
  51. heightConstraint,
  52. widthConstraint,
  53. messageTextView.leftAnchor.constraint(equalTo: self.messageBodyView.leftAnchor),
  54. messageTextView.rightAnchor.constraint(equalTo: self.messageBodyView.rightAnchor),
  55. messageTextView.topAnchor.constraint(equalTo: filePreviewImageView.bottomAnchor, constant: 10),
  56. messageTextView.bottomAnchor.constraint(equalTo: self.messageBodyView.bottomAnchor)
  57. ])
  58. }
  59. guard let filePreviewImageView = self.filePreviewImageView,
  60. let messageTextView = self.messageTextView
  61. else { return }
  62. messageTextView.attributedText = message.parsedMarkdownForChat()
  63. if message.message == "{file}" {
  64. messageTextView.dataDetectorTypes = []
  65. } else {
  66. messageTextView.dataDetectorTypes = .all
  67. }
  68. self.requestPreview(for: message, with: account)
  69. if !message.sendingFailed {
  70. if message.isTemporary {
  71. self.addActivityIndicator(with: 0)
  72. } else if let fileStatus = message.file().fileStatus {
  73. if fileStatus.isDownloading, fileStatus.downloadProgress < 1 {
  74. self.addActivityIndicator(with: Float(fileStatus.downloadProgress))
  75. }
  76. }
  77. }
  78. if let contactImage = message.file().contactPhotoImage() {
  79. filePreviewImageView.image = contactImage
  80. }
  81. }
  82. func prepareForReuseFileCell() {
  83. self.filePreviewImageView?.cancelImageDownloadTask()
  84. self.filePreviewImageView?.layer.borderWidth = 0
  85. self.filePreviewImageView?.image = nil
  86. self.filePreviewPlayIconImageView?.isHidden = true
  87. self.clearFileStatusView()
  88. }
  89. // MARK: - Preview
  90. func requestPreview(for message: NCChatMessage, with account: TalkAccount) {
  91. // Don't request a preview if we know that there's none
  92. guard let file = message.file(), file.previewAvailable else {
  93. self.showFallbackIcon(for: message)
  94. return
  95. }
  96. // In case we can determine the height before requesting the preview, adjust the imageView constraints accordingly
  97. if file.previewImageHeight > 0 {
  98. self.filePreviewImageViewHeightConstraint?.constant = CGFloat(file.previewImageHeight)
  99. } else {
  100. let estimatedPreviewHeight = BaseChatTableViewCell.getEstimatedPreviewSize(for: message)
  101. if estimatedPreviewHeight > 0 {
  102. self.filePreviewImageViewHeightConstraint?.constant = estimatedPreviewHeight
  103. }
  104. }
  105. self.filePreviewActivityIndicator?.isHidden = false
  106. self.filePreviewActivityIndicator?.startAnimating()
  107. if message.isAnimatableGif {
  108. self.requestGifPreview(for: message, with: account)
  109. } else {
  110. self.requestDefaultPreview(for: message, with: account)
  111. }
  112. }
  113. func requestGifPreview(for message: NCChatMessage, with account: TalkAccount) {
  114. guard let fileId = message.file()?.parameterId else { return }
  115. let fileControllerWrapper = NCChatFileControllerWrapper()
  116. self.fileControllerWrapper = fileControllerWrapper
  117. fileControllerWrapper.downloadFile(withFileId: fileId) { fileLocalPath in
  118. // Check if we are still on the same cell
  119. guard let cellMessage = self.message, let imageView = self.filePreviewImageView, cellMessage.file().parameterId == fileId
  120. else {
  121. // Different cell, don't do anything
  122. return
  123. }
  124. guard let fileLocalPath, let data = try? Data(contentsOf: URL(fileURLWithPath: fileLocalPath)),
  125. let gifImage = try? UIImage(gifData: data), let baseImage = UIImage(data: data) else {
  126. // No gif, try to request a normal preview
  127. self.requestDefaultPreview(for: message, with: account)
  128. return
  129. }
  130. imageView.setGifImage(gifImage)
  131. self.adjustImageView(toImageSize: baseImage, ofMessage: message)
  132. }
  133. }
  134. func requestDefaultPreview(for message: NCChatMessage, with account: TalkAccount) {
  135. guard let file = message.file() else { return }
  136. let requestedHeight = Int(3 * fileMessageCellFileMaxPreviewHeight)
  137. guard let previewRequest = NCAPIController.sharedInstance().createPreviewRequest(forFile: file.parameterId, withMaxHeight: requestedHeight, using: account) else { return }
  138. self.filePreviewImageView?.setImageWith(previewRequest, placeholderImage: nil, success: { [weak self] _, _, image in
  139. guard let self, let imageView = self.filePreviewImageView else { return }
  140. imageView.image = image
  141. self.adjustImageView(toImageSize: image, ofMessage: message)
  142. }, failure: { _, _, _ in
  143. self.showFallbackIcon(for: message)
  144. })
  145. }
  146. func adjustImageView(toImageSize image: UIImage, ofMessage message: NCChatMessage) {
  147. guard let imageView = self.filePreviewImageView, let file = message.file() else { return }
  148. let isVideoFile = NCUtils.isVideo(fileType: file.mimetype)
  149. let isMediaFile = isVideoFile || NCUtils.isImage(fileType: file.mimetype)
  150. self.filePreviewActivityIndicator?.isHidden = true
  151. self.filePreviewActivityIndicator?.stopAnimating()
  152. let imageSize = CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale)
  153. let previewSize = BaseChatTableViewCell.getPreviewSize(from: imageSize, isMediaFile)
  154. if !previewSize.width.isFinite || !previewSize.height.isFinite {
  155. self.showFallbackIcon(for: message)
  156. return
  157. }
  158. imageView.layer.borderColor = UIColor.secondarySystemFill.cgColor
  159. imageView.layer.borderWidth = 1
  160. self.filePreviewImageViewHeightConstraint?.constant = previewSize.height
  161. self.filePreviewImageViewWidthConstraint?.constant = previewSize.width
  162. if isVideoFile {
  163. // only show the play icon if there is an image preview (not on top of the default video placeholder)
  164. self.filePreviewPlayIconImageView?.isHidden = false
  165. // if the video preview is very narrow, make the play icon fit inside
  166. self.filePreviewPlayIconImageView?.frame = CGRect(x: 0, y: 0, width: min(min(previewSize.height, previewSize.width), fileMessageCellVideoPlayIconSize), height: min(min(previewSize.height, previewSize.width), fileMessageCellVideoPlayIconSize))
  167. self.filePreviewPlayIconImageView?.center = CGPoint(x: previewSize.width / 2.0, y: previewSize.height / 2.0)
  168. }
  169. self.delegate?.cellHasDownloadedImagePreview(withHeight: ceil(previewSize.height), for: message)
  170. }
  171. func showFallbackIcon(for message: NCChatMessage) {
  172. let imageName = NCUtils.previewImage(forMimeType: message.file().mimetype)
  173. if let image = UIImage(named: imageName) {
  174. let size = CGSize(width: fileMessageCellFileMaxPreviewWidth, height: fileMessageCellFileMaxPreviewHeight)
  175. if let renderedImage = NCUtils.renderAspectImage(image: image, ofSize: size, centerImage: false) {
  176. self.filePreviewImageView?.image = renderedImage
  177. self.filePreviewImageViewHeightConstraint?.constant = renderedImage.size.height
  178. self.filePreviewImageViewWidthConstraint?.constant = renderedImage.size.width
  179. }
  180. }
  181. self.filePreviewActivityIndicator?.isHidden = true
  182. self.filePreviewActivityIndicator?.stopAnimating()
  183. }
  184. @objc
  185. func filePreviewTapped() {
  186. guard let message = self.message,
  187. let fileParameter = message.file(),
  188. fileParameter.path != nil, fileParameter.link != nil
  189. else { return }
  190. self.delegate?.cellWants(toDownloadFile: fileParameter, for: message)
  191. }
  192. // MARK: - Preview height calculation
  193. static func getPreviewSize(from imageSize: CGSize, _ isMediaFile: Bool) -> CGSize {
  194. var width = imageSize.width
  195. var height = imageSize.height
  196. let previewMaxHeight = isMediaFile ? fileMessageCellMediaFilePreviewHeight : fileMessageCellFileMaxPreviewHeight
  197. let previewMaxWidth = isMediaFile ? fileMessageCellMediaFileMaxPreviewWidth : fileMessageCellFileMaxPreviewWidth
  198. if height < fileMessageCellMinimumHeight {
  199. let ratio = fileMessageCellMinimumHeight / height
  200. width *= ratio
  201. if width > previewMaxWidth {
  202. width = previewMaxWidth
  203. }
  204. height = fileMessageCellMinimumHeight
  205. } else {
  206. if height > previewMaxHeight {
  207. let ratio = previewMaxHeight / height
  208. width *= ratio
  209. height = previewMaxHeight
  210. }
  211. if width > previewMaxWidth {
  212. let ratio = previewMaxWidth / width
  213. width = previewMaxWidth
  214. height *= ratio
  215. }
  216. }
  217. return CGSize(width: width, height: height)
  218. }
  219. static func getEstimatedPreviewSize(for message: NCChatMessage?) -> CGFloat {
  220. guard let message, let fileParameter = message.file() else { return 0 }
  221. // We don't have any information about the image to display
  222. if fileParameter.width == 0 && fileParameter.height == 0 {
  223. return 0
  224. }
  225. // We can only estimate the height for images and videos
  226. if !NCUtils.isVideo(fileType: fileParameter.mimetype), !NCUtils.isImage(fileType: fileParameter.mimetype) {
  227. return 0
  228. }
  229. let imageSize = CGSize(width: CGFloat(fileParameter.width), height: CGFloat(fileParameter.height))
  230. let previewSize = self.getPreviewSize(from: imageSize, true)
  231. return ceil(previewSize.height)
  232. }
  233. }