session-ios/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift
Morgan Pretty 67ad965859 Link Preview error state and UI standardisation
Added a couple of error states for Link Preview loading.
Standardised the UI creation code style.
Removed some debug and redundant code.
2022-01-13 10:05:42 +11:00

689 lines
25 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import MediaPlayer
import YYImage
import NVActivityIndicatorView
import SessionUIKit
public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
public enum Mode: UInt {
case large
case small
case attachmentApproval
}
// MARK: Properties
public let mode: Mode
public let attachment: SignalAttachment
public var audioPlayer: OWSAudioPlayer?
private var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
public var playbackState = AudioPlaybackState.stopped {
didSet {
AssertIsOnMainThread()
ensureButtonState()
}
}
public var audioProgressSeconds: CGFloat = 0
public var audioDurationSeconds: CGFloat = 0
public var contentView: UIView?
// MARK: Initializers
@available(*, unavailable, message:"use other constructor instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
// Currently we only use one mode (AttachmentApproval), so we could simplify this class, but it's kind
// of nice that it's written in a flexible way in case we'd want to use it elsewhere again in the future.
public required init(attachment: SignalAttachment, mode: MediaMessageView.Mode) {
if attachment.hasError { owsFailDebug(attachment.error.debugDescription) }
self.attachment = attachment
self.mode = mode
super.init(frame: CGRect.zero)
createViews()
setupLayout()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
private lazy var stackView: UIStackView = {
let stackView: UIStackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.distribution = .equalSpacing
switch mode {
case .attachmentApproval: stackView.spacing = 2
case .large: stackView.spacing = 10
case .small: stackView.spacing = 5
}
return stackView
}()
private lazy var loadingView: NVActivityIndicatorView = {
let view: NVActivityIndicatorView = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
return view
}()
private lazy var imageView: UIImageView = {
let view: UIImageView = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.contentMode = .scaleAspectFit
view.layer.minificationFilter = .trilinear
view.layer.magnificationFilter = .trilinear
return view
}()
private lazy var fileTypeImageView: UIImageView = {
let view: UIImageView = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let view: YYAnimatedImageView = YYAnimatedImageView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
lazy var videoPlayButton: UIImageView = {
let imageView: UIImageView = UIImageView(image: UIImage(named: "CirclePlay"))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
/// Note: This uses different assets from the `videoPlayButton` and has a 'Pause' state
private lazy var audioPlayPauseButton: UIButton = {
let button: UIButton = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.clipsToBounds = true
button.setBackgroundImage(UIColor.white.toImage(), for: .normal)
button.setBackgroundImage(UIColor.white.darken(by: 0.2).toImage(), for: .highlighted)
button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside)
return button
}()
private lazy var titleLabel: UILabel = {
let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.lineBreakMode = .byTruncatingMiddle
if let fileName: String = attachment.sourceFilename?.trimmingCharacters(in: .whitespacesAndNewlines), fileName.count > 0 {
label.text = fileName
}
else if let fileExtension: String = attachment.fileExtension {
label.text = String(
format: "ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT".localized(),
fileExtension.uppercased()
)
}
label.isHidden = ((label.text?.count ?? 0) == 0)
switch mode {
case .attachmentApproval:
label.font = UIFont.ows_boldFont(withSize: ScaleFromIPhone5To7Plus(16, 22))
label.textColor = Colors.text
case .large:
label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(18, 24))
label.textColor = Colors.accent
case .small:
label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(14, 14))
label.textColor = Colors.accent
}
return label
}()
private lazy var fileSizeLabel: UILabel = {
let fileSize: UInt = attachment.dataLength
let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
// Format string for file size label in call interstitial view.
// Embeds: {{file size as 'N mb' or 'N kb'}}.
label.text = String(format: "ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT".localized(), OWSFormat.formatFileSize(UInt(fileSize)))
label.textAlignment = .center
switch mode {
case .attachmentApproval:
label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(12, 18))
label.textColor = Colors.pinIcon
case .large:
label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(18, 24))
label.textColor = Colors.accent
case .small:
label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(14, 14))
label.textColor = Colors.accent
}
return label
}()
// MARK: - Layout
private func createViews() {
if attachment.isAnimatedImage {
createAnimatedPreview()
} else if attachment.isImage {
createImagePreview()
} else if attachment.isVideo {
createVideoPreview()
} else if attachment.isAudio {
createAudioPreview()
} else if attachment.isUrl {
createUrlPreview()
} else if attachment.isText {
// Do nothing as we will just put the text in the 'message' input
} else {
createGenericPreview()
}
}
private func setupLayout() {
// Bottom inset
}
// TODO: Any reason for not just using UIStackView
private func wrapViewsInVerticalStack(subviews: [UIView]) -> UIView {
assert(subviews.count > 0)
let stackView = UIView()
var lastView: UIView?
for subview in subviews {
stackView.addSubview(subview)
subview.autoHCenterInSuperview()
if lastView == nil {
subview.autoPinEdge(toSuperviewEdge: .top)
} else {
subview.autoPinEdge(.top, to: .bottom, of: lastView!, withOffset: 10)
}
lastView = subview
}
lastView?.autoPinEdge(toSuperviewEdge: .bottom)
return stackView
}
private func wrapViewsInHorizontalStack(subviews: [UIView]) -> UIView {
assert(subviews.count > 0)
let stackView = UIView()
var lastView: UIView?
for subview in subviews {
stackView.addSubview(subview)
subview.autoVCenterInSuperview()
if lastView == nil {
subview.autoPinEdge(toSuperviewEdge: .left)
} else {
subview.autoPinEdge(.left, to: .right, of: lastView!, withOffset: 10)
}
lastView = subview
}
lastView?.autoPinEdge(toSuperviewEdge: .right)
return stackView
}
// private func stackSpacing() -> CGFloat {
// switch mode {
// case .large, .attachmentApproval:
// return CGFloat(10)
// case .small:
// return CGFloat(5)
// }
// }
private func createAudioPreview() {
guard let dataUrl = attachment.dataUrl else {
createGenericPreview()
return
}
audioPlayer = OWSAudioPlayer(mediaUrl: dataUrl, audioBehavior: .playback, delegate: self)
imageView.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate)
imageView.tintColor = Colors.text
fileTypeImageView.image = UIImage(named: "table_ic_notification_sound")?
.withRenderingMode(.alwaysTemplate)
fileTypeImageView.tintColor = Colors.text
setAudioIconToPlay()
self.addSubview(stackView)
self.addSubview(audioPlayPauseButton)
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(UIView.vSpacer(0))
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(fileSizeLabel)
imageView.addSubview(fileTypeImageView)
let imageSize: CGFloat = {
switch mode {
case .large: return 200
case .attachmentApproval: return 150
case .small: return 80
}
}()
let audioButtonSize: CGFloat = (imageSize / 2.5)
audioPlayPauseButton.layer.cornerRadius = (audioButtonSize / 2)
NSLayoutConstraint.activate([
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.widthAnchor.constraint(equalTo: widthAnchor),
stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
imageView.widthAnchor.constraint(equalToConstant: imageSize),
imageView.heightAnchor.constraint(equalToConstant: imageSize),
titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)),
fileSizeLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)),
fileTypeImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
fileTypeImageView.centerYAnchor.constraint(
equalTo: imageView.centerYAnchor,
constant: ceil(imageSize * 0.15)
),
fileTypeImageView.widthAnchor.constraint(
equalTo: fileTypeImageView.heightAnchor,
multiplier: ((fileTypeImageView.image?.size.width ?? 1) / (fileTypeImageView.image?.size.height ?? 1))
),
fileTypeImageView.widthAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 0.5),
audioPlayPauseButton.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
audioPlayPauseButton.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
audioPlayPauseButton.widthAnchor.constraint(equalToConstant: audioButtonSize),
audioPlayPauseButton.heightAnchor.constraint(equalToConstant: audioButtonSize)
])
}
private func createAnimatedPreview() {
guard attachment.isValidImage else {
createGenericPreview()
return
}
guard let dataUrl = attachment.dataUrl else {
createGenericPreview()
return
}
guard let image = YYImage(contentsOfFile: dataUrl.path) else {
createGenericPreview()
return
}
guard image.size.width > 0 && image.size.height > 0 else {
createGenericPreview()
return
}
animatedImageView.image = image
let aspectRatio: CGFloat = (image.size.width / image.size.height)
let clampedRatio: CGFloat = CGFloatClamp(aspectRatio, 0.05, 95.0)
addSubview(animatedImageView)
// addSubviewWithScaleAspectFitLayout(view: animatedImageView, aspectRatio: aspectRatio)
contentView = animatedImageView
NSLayoutConstraint.activate([
animatedImageView.centerXAnchor.constraint(equalTo: centerXAnchor),
animatedImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
animatedImageView.widthAnchor.constraint(
equalTo: animatedImageView.heightAnchor,
multiplier: clampedRatio
),
animatedImageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor),
animatedImageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor)
])
}
// private func addSubviewWithScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) {
// self.addSubview(view)
// // This emulates the behavior of contentMode = .scaleAspectFit using
// // iOS auto layout constraints.
// //
// // This allows ConversationInputToolbar to place the "cancel" button
// // in the upper-right hand corner of the preview content.
// view.autoCenterInSuperview()
// view.autoPin(toAspectRatio: aspectRatio)
// view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
// view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
// }
private func createImagePreview() {
guard attachment.isValidImage else {
createGenericPreview()
return
}
guard let image = attachment.image() else {
createGenericPreview()
return
}
guard image.size.width > 0 && image.size.height > 0 else {
createGenericPreview()
return
}
imageView.image = image
// imageView.layer.minificationFilter = .trilinear
// imageView.layer.magnificationFilter = .trilinear
let aspectRatio = image.size.width / image.size.height
let clampedRatio: CGFloat = CGFloatClamp(aspectRatio, 0.05, 95.0)
// addSubviewWithScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio)
contentView = imageView
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
imageView.widthAnchor.constraint(
equalTo: imageView.heightAnchor,
multiplier: clampedRatio
),
imageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor),
imageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor)
])
}
private func createVideoPreview() {
guard attachment.isValidVideo else {
createGenericPreview()
return
}
guard let image = attachment.videoPreview() else {
createGenericPreview()
return
}
guard image.size.width > 0 && image.size.height > 0 else {
createGenericPreview()
return
}
imageView.image = image
self.addSubview(imageView)
let aspectRatio = image.size.width / image.size.height
let clampedRatio: CGFloat = CGFloatClamp(aspectRatio, 0.05, 95.0)
contentView = imageView
// Attachment approval provides it's own play button to keep it
// at the proper zoom scale.
if mode != .attachmentApproval {
self.addSubview(videoPlayButton)
}
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
imageView.widthAnchor.constraint(
equalTo: imageView.heightAnchor,
multiplier: clampedRatio
),
imageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor),
imageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor)
])
// Attachment approval provides it's own play button to keep it
// at the proper zoom scale.
if mode != .attachmentApproval {
self.addSubview(videoPlayButton)
NSLayoutConstraint.activate([
videoPlayButton.centerXAnchor.constraint(equalTo: centerXAnchor),
videoPlayButton.centerYAnchor.constraint(equalTo: centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 72),
imageView.heightAnchor.constraint(equalToConstant: 72)
])
}
}
private func createUrlPreview() {
// If link previews aren't enabled then use a fallback state
guard let linkPreviewURL: String = OWSLinkPreview.previewURL(forRawBodyText: attachment.text()) else {
titleLabel.text = "vc_share_link_previews_disabled_title".localized()
titleLabel.isHidden = false
fileSizeLabel.text = "vc_share_link_previews_disabled_explanation".localized()
fileSizeLabel.textColor = Colors.text
fileSizeLabel.numberOfLines = 0
self.addSubview(stackView)
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(UIView.vSpacer(10))
stackView.addArrangedSubview(fileSizeLabel)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.widthAnchor.constraint(equalTo: widthAnchor, constant: -(32 * 2)),
stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor)
])
return
}
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
stackView.axis = .horizontal
stackView.distribution = .fill
imageView.clipsToBounds = true
imageView.image = UIImage(named: "Link")?.withTint(Colors.text)
imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView
imageView.contentMode = .center
imageView.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
imageView.layer.cornerRadius = 8
loadingView.isHidden = false
loadingView.startAnimating()
titleLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
titleLabel.text = linkPreviewURL
titleLabel.textAlignment = .left
titleLabel.numberOfLines = 2
titleLabel.isHidden = false
self.addSubview(stackView)
self.addSubview(loadingView)
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(UIView.vhSpacer(10, 0))
stackView.addArrangedSubview(titleLabel)
let imageSize: CGFloat = {
switch mode {
case .large: return 120
case .attachmentApproval, .small: return 80
}
}()
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.widthAnchor.constraint(equalTo: widthAnchor, constant: -(32 * 2)),
stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
imageView.widthAnchor.constraint(equalToConstant: imageSize),
imageView.heightAnchor.constraint(equalToConstant: imageSize),
loadingView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
loadingView.widthAnchor.constraint(equalToConstant: ceil(imageSize / 3)),
loadingView.heightAnchor.constraint(equalToConstant: ceil(imageSize / 3))
])
// Build the link preview
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.done { [weak self] draft in
// TODO: Look at refactoring this behaviour to consolidate attachment mutations
self?.attachment.linkPreviewDraft = draft
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
// Update the UI
self?.titleLabel.text = (draft.title ?? self?.titleLabel.text)
self?.loadingView.alpha = 0
self?.loadingView.stopAnimating()
self?.imageView.alpha = 1
if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) {
self?.imageView.image = loadedImage
self?.imageView.contentMode = .scaleAspectFill
}
}
.catch { [weak self] _ in
self?.titleLabel.attributedText = NSMutableAttributedString(string: linkPreviewURL)
.rtlSafeAppend(
"\n\("vc_share_link_previews_error".localized())",
attributes: [
NSAttributedString.Key.font: UIFont.ows_regularFont(
withSize: Values.verySmallFontSize
),
NSAttributedString.Key.foregroundColor: self?.fileSizeLabel.textColor
]
.compactMapValues { $0 }
)
self?.loadingView.alpha = 0
self?.loadingView.stopAnimating()
self?.imageView.alpha = 1
}
.retainUntilComplete()
}
private func createGenericPreview() {
imageView.image = UIImage(named: "FileLarge")
self.addSubview(stackView)
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(UIView.vSpacer(5))
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(fileSizeLabel)
imageView.addSubview(fileTypeImageView)
let imageSize: CGFloat = {
switch mode {
case .large: return 200
case .attachmentApproval: return 150
case .small: return 80
}
}()
NSLayoutConstraint.activate([
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.widthAnchor.constraint(equalTo: widthAnchor),
stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
imageView.widthAnchor.constraint(equalToConstant: imageSize),
imageView.heightAnchor.constraint(equalToConstant: imageSize),
titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)),
fileSizeLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)),
fileTypeImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
fileTypeImageView.centerYAnchor.constraint(
equalTo: imageView.centerYAnchor,
constant: 25
),
fileTypeImageView.widthAnchor.constraint(
equalTo: fileTypeImageView.heightAnchor,
multiplier: ((fileTypeImageView.image?.size.width ?? 1) / (fileTypeImageView.image?.size.height ?? 1))
),
fileTypeImageView.widthAnchor.constraint(
equalTo: imageView.widthAnchor, constant: -75
)
])
}
// MARK: - Event Handlers
@objc func audioPlayPauseButtonPressed(sender: UIButton) {
audioPlayer?.togglePlayState()
}
// MARK: - OWSAudioPlayerDelegate
public func audioPlaybackState() -> AudioPlaybackState {
return playbackState
}
public func setAudioPlaybackState(_ value: AudioPlaybackState) {
playbackState = value
}
public func showInvalidAudioFileAlert() {
OWSAlerts.showErrorAlert(message: NSLocalizedString("INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE", comment: "Message for the alert indicating that an audio file is invalid."))
}
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) {
// Do nothing
}
private func ensureButtonState() {
switch playbackState {
case .playing: setAudioIconToPause()
default: setAudioIconToPlay()
}
}
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
audioProgressSeconds = progress
audioDurationSeconds = duration
}
private func setAudioIconToPlay() {
audioPlayPauseButton.setImage(UIImage(named: "Play"), for: .normal)
}
private func setAudioIconToPause() {
audioPlayPauseButton.setImage(UIImage(named: "Pause"), for: .normal)
}
}