604 lines
25 KiB
Swift
604 lines
25 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
public class CVMediaAlbumView: ManualStackViewWithLayer {
|
|
private var items = [CVMediaAlbumItem]()
|
|
private var isBorderless = false
|
|
|
|
public var itemViews = [CVMediaView]()
|
|
|
|
public var moreItemsView: CVMediaView?
|
|
|
|
private static let kSpacingPts: CGFloat = 2
|
|
private static let kMaxItems = 5
|
|
|
|
// Not all of these sub-stacks maybe used.
|
|
private let subStack1 = ManualStackView(name: "CVMediaAlbumView.subStack1")
|
|
private let subStack2 = ManualStackView(name: "CVMediaAlbumView.subStack2")
|
|
|
|
public init() {
|
|
super.init(name: "media album view")
|
|
}
|
|
|
|
public func configure(mediaCache: CVMediaCache,
|
|
items: [CVMediaAlbumItem],
|
|
interaction: TSInteraction,
|
|
isBorderless: Bool,
|
|
cellMeasurement: CVCellMeasurement,
|
|
conversationStyle: ConversationStyle) {
|
|
|
|
guard let maxMessageWidth = cellMeasurement.value(key: Self.measurementKey_maxMessageWidth) else {
|
|
owsFailDebug("Missing maxMessageWidth.")
|
|
return
|
|
}
|
|
guard let imageArrangementWrapper: CVMeasurementImageArrangement = cellMeasurement.object(key: Self.measurementKey_imageArrangement) else {
|
|
owsFailDebug("Missing imageArrangement.")
|
|
return
|
|
}
|
|
let imageArrangement = imageArrangementWrapper.imageArrangement
|
|
|
|
self.items = items
|
|
|
|
let viewSizePoints = imageArrangement.worstCaseMediaRenderSizePoints(conversationStyle: conversationStyle)
|
|
self.itemViews = CVMediaAlbumView.itemsToDisplay(forItems: items).map { item in
|
|
let thumbnailQuality = Self.thumbnailQuality(
|
|
mediaSizePoints: item.mediaSize,
|
|
viewSizePoints: viewSizePoints
|
|
)
|
|
return CVMediaView(
|
|
mediaCache: mediaCache,
|
|
attachment: item.attachment,
|
|
interaction: interaction,
|
|
maxMessageWidth: maxMessageWidth,
|
|
isBorderless: isBorderless,
|
|
isLoopingVideo: item.renderingFlag == .shouldLoop,
|
|
isBroken: item.isBroken,
|
|
thumbnailQuality: thumbnailQuality,
|
|
conversationStyle: conversationStyle
|
|
)
|
|
}
|
|
|
|
self.isBorderless = isBorderless
|
|
self.backgroundColor = isBorderless ? .clear : Theme.backgroundColor
|
|
|
|
createContents(imageArrangement: imageArrangement,
|
|
cellMeasurement: cellMeasurement)
|
|
}
|
|
|
|
public override func reset() {
|
|
super.reset()
|
|
|
|
subStack1.reset()
|
|
subStack2.reset()
|
|
|
|
items.removeAll()
|
|
itemViews.removeAll()
|
|
moreItemsView = nil
|
|
|
|
removeAllSubviews()
|
|
}
|
|
|
|
private func createContents(imageArrangement: ImageArrangement,
|
|
cellMeasurement: CVCellMeasurement) {
|
|
|
|
let outerStackView = self
|
|
let subStack1 = self.subStack1
|
|
let subStack2 = self.subStack2
|
|
|
|
subStack1.reset()
|
|
subStack2.reset()
|
|
|
|
var outerViews = [UIView]()
|
|
let imageGroup1 = imageArrangement.imageGroup1
|
|
let itemViews1 = Array(itemViews.prefix(imageGroup1.imageCount))
|
|
subStack1.configure(config: imageArrangement.innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_substack1,
|
|
subviews: itemViews1)
|
|
outerViews.append(subStack1)
|
|
|
|
if let imageGroup2 = imageArrangement.imageGroup2 {
|
|
owsAssertDebug(itemViews.count == imageGroup1.imageCount + imageGroup2.imageCount)
|
|
|
|
let itemViews2 = Array(itemViews.suffix(from: imageGroup1.imageCount))
|
|
|
|
if items.count > CVMediaAlbumView.kMaxItems {
|
|
guard let lastView = itemViews2.last else {
|
|
owsFailDebug("Missing lastView")
|
|
return
|
|
}
|
|
|
|
moreItemsView = lastView
|
|
|
|
let tintView = UIView()
|
|
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
|
lastView.addSubview(tintView)
|
|
subStack2.layoutSubviewToFillSuperviewEdges(tintView)
|
|
|
|
let moreCount = max(1, items.count - CVMediaAlbumView.kMaxItems)
|
|
let moreCountText = OWSFormat.formatInt(moreCount)
|
|
let moreText = String(format: OWSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
|
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."),
|
|
moreCountText)
|
|
let moreLabel = CVLabel()
|
|
moreLabel.text = moreText
|
|
moreLabel.textColor = UIColor.ows_white
|
|
// We don't want to use dynamic text here.
|
|
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
|
lastView.addSubview(moreLabel)
|
|
subStack2.addLayoutBlock { _ in
|
|
let labelSize = moreLabel.sizeThatFitsMaxSize
|
|
let labelOrigin = ((lastView.bounds.size - labelSize) * 0.5).asPoint
|
|
moreLabel.frame = CGRect(origin: labelOrigin, size: labelSize)
|
|
}
|
|
}
|
|
|
|
subStack2.configure(config: imageArrangement.innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_substack2,
|
|
subviews: itemViews2)
|
|
outerViews.append(subStack2)
|
|
} else {
|
|
owsAssertDebug(itemViews.count == imageGroup1.imageCount)
|
|
}
|
|
|
|
outerStackView.configure(config: imageArrangement.outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviews: outerViews)
|
|
|
|
for itemView in itemViews {
|
|
guard moreItemsView != itemView else {
|
|
// Don't display the caption indicator on
|
|
// the "more" item, if any.
|
|
continue
|
|
}
|
|
guard let index = itemViews.firstIndex(of: itemView) else {
|
|
owsFailDebug("Couldn't determine index of item view.")
|
|
continue
|
|
}
|
|
let item = items[index]
|
|
guard item.hasCaption else {
|
|
continue
|
|
}
|
|
guard let icon = UIImage(named: "media_album_caption") else {
|
|
owsFailDebug("Couldn't load icon.")
|
|
continue
|
|
}
|
|
let iconView = CVImageView(image: icon)
|
|
itemView.addSubview(iconView)
|
|
itemView.addLayoutBlock { view in
|
|
let inset: CGFloat = 6
|
|
let x = (CurrentAppContext().isRTL
|
|
? view.width - (icon.size.width + inset)
|
|
: inset)
|
|
iconView.frame = CGRect(x: x,
|
|
y: inset,
|
|
width: icon.size.width,
|
|
height: icon.size.height)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func loadMedia() {
|
|
for itemView in itemViews {
|
|
itemView.loadMedia()
|
|
}
|
|
}
|
|
|
|
public func unloadMedia() {
|
|
for itemView in itemViews {
|
|
itemView.unloadMedia()
|
|
}
|
|
}
|
|
|
|
private class func itemsToDisplay(forItems items: [CVMediaAlbumItem]) -> [CVMediaAlbumItem] {
|
|
// TODO: Unless design changes, we want to display
|
|
// items which are still downloading and invalid
|
|
// items.
|
|
let validItems = items
|
|
guard validItems.count < kMaxItems else {
|
|
return Array(validItems[0..<kMaxItems])
|
|
}
|
|
return validItems
|
|
}
|
|
|
|
private static var hStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .horizontal,
|
|
alignment: .fill,
|
|
spacing: Self.kSpacingPts,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private static var vStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: Self.kSpacingPts,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private static let measurementKey_maxMessageWidth: String = "CVMediaAlbumView.maxMessageWidth"
|
|
private static let measurementKey_imageArrangement: String = "CVMediaAlbumView.imageArrangement"
|
|
private static let measurementKey_outerStack = "CVMediaAlbumView.measurementKey_outerStack"
|
|
private static let measurementKey_substack1 = "CVMediaAlbumView.measurementKey_substack1"
|
|
private static let measurementKey_substack2 = "CVMediaAlbumView.measurementKey_substack2"
|
|
|
|
public class func measure(maxWidth: CGFloat,
|
|
minWidth: CGFloat,
|
|
items: [CVMediaAlbumItem],
|
|
measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
|
|
func measureImageStackLayout(imageSize: CGSize,
|
|
imageCount: Int,
|
|
stackConfig: CVStackViewConfig,
|
|
measurementKey: String) -> CGSize {
|
|
let subviewInfos: [ManualStackSubviewInfo] = (0..<imageCount).map { _ in
|
|
imageSize.asManualSubviewInfo
|
|
}
|
|
let stackMeasurement = ManualStackView.measure(config: stackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: measurementKey,
|
|
subviewInfos: subviewInfos)
|
|
return stackMeasurement.measuredSize
|
|
}
|
|
|
|
let imageArrangement = Self.imageArrangement(minWidth: minWidth,
|
|
maxWidth: maxWidth,
|
|
items: items)
|
|
|
|
measurementBuilder.setObject(key: Self.measurementKey_imageArrangement,
|
|
value: CVMeasurementImageArrangement(imageArrangement: imageArrangement))
|
|
measurementBuilder.setValue(key: Self.measurementKey_maxMessageWidth, value: maxWidth)
|
|
|
|
var groupInfos = [ManualStackSubviewInfo]()
|
|
let imageGroup1 = imageArrangement.imageGroup1
|
|
groupInfos.append(measureImageStackLayout(imageSize: imageGroup1.imageSize,
|
|
imageCount: imageGroup1.imageCount,
|
|
stackConfig: imageArrangement.innerStackConfig,
|
|
measurementKey: Self.measurementKey_substack1).asManualSubviewInfo)
|
|
if let imageGroup2 = imageArrangement.imageGroup2 {
|
|
groupInfos.append(measureImageStackLayout(imageSize: imageGroup2.imageSize,
|
|
imageCount: imageGroup2.imageCount,
|
|
stackConfig: imageArrangement.innerStackConfig,
|
|
measurementKey: Self.measurementKey_substack2).asManualSubviewInfo)
|
|
}
|
|
let outerStackMeasurement = ManualStackView.measure(config: imageArrangement.outerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviewInfos: groupInfos,
|
|
maxWidth: maxWidth)
|
|
return outerStackMeasurement.measuredSize
|
|
}
|
|
|
|
fileprivate struct ImageGroup: Equatable {
|
|
let imageCount: Int
|
|
let imageSize: CGSize
|
|
|
|
var imageSizes: [CGSize] {
|
|
[CGSize](repeating: imageSize, count: imageCount)
|
|
}
|
|
}
|
|
|
|
fileprivate enum ImageArrangement: Equatable {
|
|
case single(row: ImageGroup)
|
|
case oneHorizontalRow(row: ImageGroup)
|
|
case twoHorizontalRows(row1: ImageGroup, row2: ImageGroup)
|
|
case twoVerticalColumns(column1: ImageGroup, column2: ImageGroup)
|
|
|
|
var outerStackConfig: CVStackViewConfig {
|
|
switch self {
|
|
case .single,
|
|
.oneHorizontalRow,
|
|
.twoHorizontalRows:
|
|
return CVMediaAlbumView.vStackConfig
|
|
case .twoVerticalColumns:
|
|
return CVMediaAlbumView.hStackConfig
|
|
}
|
|
}
|
|
|
|
var innerStackConfig: CVStackViewConfig {
|
|
switch self {
|
|
case .single,
|
|
.oneHorizontalRow,
|
|
.twoHorizontalRows:
|
|
return CVMediaAlbumView.hStackConfig
|
|
case .twoVerticalColumns:
|
|
return CVMediaAlbumView.vStackConfig
|
|
}
|
|
}
|
|
|
|
var imageGroup1: ImageGroup {
|
|
switch self {
|
|
case .single(let row):
|
|
return row
|
|
case .oneHorizontalRow(let row):
|
|
return row
|
|
case .twoHorizontalRows(let row1, _):
|
|
return row1
|
|
case .twoVerticalColumns(let column1, _):
|
|
return column1
|
|
}
|
|
}
|
|
|
|
var imageGroup2: ImageGroup? {
|
|
switch self {
|
|
case .single:
|
|
return nil
|
|
case .oneHorizontalRow:
|
|
return nil
|
|
case .twoHorizontalRows(_, let row2):
|
|
return row2
|
|
case .twoVerticalColumns(_, let column2):
|
|
return column2
|
|
}
|
|
}
|
|
|
|
func worstCaseMediaRenderSizePoints(conversationStyle: ConversationStyle) -> CGSize {
|
|
let maxMediaMessageWidth = conversationStyle.maxMediaMessageWidth
|
|
|
|
func worstCaseMediaRenderSize(horizontalRow row: ImageGroup,
|
|
rowSize: CGSize) -> CGSize {
|
|
return CGSize(width: rowSize.width / CGFloat(row.imageCount),
|
|
height: rowSize.height)
|
|
}
|
|
|
|
switch self {
|
|
case .single:
|
|
return .square(maxMediaMessageWidth)
|
|
default:
|
|
let imageSizes = self.imageSizes
|
|
return CGSize(width: imageSizes.map { $0.width }.reduce(0, max),
|
|
height: imageSizes.map { $0.height }.reduce(0, max))
|
|
}
|
|
}
|
|
|
|
var imageSizes: [CGSize] {
|
|
switch self {
|
|
case .single(let row):
|
|
return row.imageSizes
|
|
case .oneHorizontalRow(let row):
|
|
return row.imageSizes
|
|
case .twoHorizontalRows(let row1, let row2):
|
|
return row1.imageSizes + row2.imageSizes
|
|
case .twoVerticalColumns(let column1, let column2):
|
|
return column1.imageSizes + column2.imageSizes
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate class CVMeasurementImageArrangement: CVMeasurementObject {
|
|
fileprivate let imageArrangement: ImageArrangement
|
|
|
|
fileprivate init(imageArrangement: ImageArrangement) {
|
|
self.imageArrangement = imageArrangement
|
|
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Equatable
|
|
|
|
public static func == (lhs: CVMeasurementImageArrangement, rhs: CVMeasurementImageArrangement) -> Bool {
|
|
lhs.imageArrangement == rhs.imageArrangement
|
|
}
|
|
}
|
|
|
|
private class func imageArrangement(minWidth: CGFloat,
|
|
maxWidth: CGFloat,
|
|
items: [CVMediaAlbumItem]) -> ImageArrangement {
|
|
|
|
let itemCount = itemsToDisplay(forItems: items).count
|
|
switch itemCount {
|
|
case 0:
|
|
// X
|
|
// Reflects content size.
|
|
owsFailDebug("Missing items.")
|
|
|
|
let imageSize = CGSize(square: maxWidth)
|
|
let row = ImageGroup(imageCount: 1, imageSize: imageSize)
|
|
return .single(row: row)
|
|
case 1:
|
|
// X
|
|
// Reflects content size.
|
|
|
|
// TODO: I'm not sure this is yielding the ideal results,
|
|
// e.g. for extremely wide or tall images.
|
|
let buildSingleMediaSize = { () -> CGSize? in
|
|
guard items.count == 1 else {
|
|
// More than one piece of media.
|
|
return nil
|
|
}
|
|
guard let mediaAlbumItem = items.first else {
|
|
owsFailDebug("Missing mediaAlbumItem.")
|
|
return nil
|
|
}
|
|
|
|
let mediaSize = mediaAlbumItem.mediaSize
|
|
guard mediaSize.width > 0 && mediaSize.height > 0 else {
|
|
// This could be a pending or invalid attachment.
|
|
return nil
|
|
}
|
|
// Honor the content aspect ratio for single media.
|
|
var contentAspectRatio = mediaSize.width / mediaSize.height
|
|
// Clamp the aspect ratio so that very thin/wide content is presented
|
|
// in a reasonable way.
|
|
let minAspectRatio: CGFloat = 0.35
|
|
let maxAspectRatio: CGFloat = 1 / minAspectRatio
|
|
owsAssertDebug(minAspectRatio <= maxAspectRatio)
|
|
contentAspectRatio = contentAspectRatio.clamp(minAspectRatio, maxAspectRatio)
|
|
|
|
let maxMediaWidth: CGFloat = maxWidth
|
|
let maxMediaHeight: CGFloat = maxWidth
|
|
var mediaWidth: CGFloat = maxMediaHeight * contentAspectRatio
|
|
|
|
// We may need to reserve space for a footer overlay.
|
|
mediaWidth = max(mediaWidth, minWidth)
|
|
|
|
var mediaHeight: CGFloat = maxMediaHeight
|
|
if mediaWidth > maxMediaWidth {
|
|
mediaWidth = maxMediaWidth
|
|
mediaHeight = maxMediaWidth / contentAspectRatio
|
|
}
|
|
|
|
// We don't want to blow up small images unnecessarily.
|
|
let minimumSize: CGFloat = max(150, minWidth)
|
|
let shortSrcDimension: CGFloat = min(mediaSize.width, mediaSize.height)
|
|
let shortDstDimension: CGFloat = min(mediaWidth, mediaHeight)
|
|
if shortDstDimension > minimumSize && shortDstDimension > shortSrcDimension {
|
|
let factor: CGFloat = minimumSize / shortDstDimension
|
|
mediaWidth *= factor
|
|
mediaHeight *= factor
|
|
}
|
|
|
|
return CGSize(width: mediaWidth, height: mediaHeight).round
|
|
}
|
|
|
|
let imageSize = buildSingleMediaSize() ?? CGSize(square: maxWidth)
|
|
let row = ImageGroup(imageCount: 1, imageSize: imageSize)
|
|
return .single(row: row)
|
|
case 2:
|
|
// X X
|
|
// side-by-side.
|
|
let imageSize = CGSize(square: floor((maxWidth - kSpacingPts) / 2))
|
|
return .oneHorizontalRow(row: ImageGroup(imageCount: 2, imageSize: imageSize))
|
|
case 3:
|
|
// x
|
|
// X x
|
|
// Big on left, 2 small on right.
|
|
let smallImageSize: CGFloat = floor((maxWidth - kSpacingPts * 2) / 3)
|
|
let bigImageSize: CGFloat = smallImageSize * 2 + kSpacingPts
|
|
return .twoVerticalColumns(column1: ImageGroup(imageCount: 1, imageSize: .square(bigImageSize)),
|
|
column2: ImageGroup(imageCount: 2, imageSize: .square(smallImageSize)))
|
|
case 4:
|
|
// XX
|
|
// XX
|
|
// Square
|
|
let imageSize = CGSize(square: floor((maxWidth - CVMediaAlbumView.kSpacingPts) / 2))
|
|
return .twoHorizontalRows(row1: ImageGroup(imageCount: 2, imageSize: imageSize),
|
|
row2: ImageGroup(imageCount: 2, imageSize: imageSize))
|
|
default:
|
|
// X X
|
|
// xxx
|
|
// 2 big on top, 3 small on bottom.
|
|
let bigImageSize: CGFloat = floor((maxWidth - kSpacingPts) / 2)
|
|
let smallImageSize: CGFloat = floor((maxWidth - kSpacingPts * 2) / 3)
|
|
return .twoHorizontalRows(row1: ImageGroup(imageCount: 2, imageSize: .square(bigImageSize)),
|
|
row2: ImageGroup(imageCount: 3, imageSize: .square(smallImageSize)))
|
|
}
|
|
}
|
|
|
|
public func mediaView(forLocation location: CGPoint) -> CVMediaView? {
|
|
var bestMediaView: CVMediaView?
|
|
var bestDistance: CGFloat = 0
|
|
for itemView in itemViews {
|
|
let itemCenter = convert(itemView.center, from: itemView.superview)
|
|
let distance = location.distance(itemCenter)
|
|
if bestMediaView != nil && distance > bestDistance {
|
|
continue
|
|
}
|
|
bestMediaView = itemView
|
|
bestDistance = distance
|
|
}
|
|
return bestMediaView
|
|
}
|
|
|
|
public func isMoreItemsView(mediaView: CVMediaView) -> Bool {
|
|
return moreItemsView == mediaView
|
|
}
|
|
|
|
private static func thumbnailQuality(
|
|
mediaSizePoints: CGSize,
|
|
viewSizePoints: CGSize
|
|
) -> AttachmentThumbnailQuality {
|
|
guard mediaSizePoints.isNonEmpty,
|
|
viewSizePoints.isNonEmpty else {
|
|
owsFailDebug("Invalid sizes. mediaSizePoints: \(mediaSizePoints), viewSizePoints: \(viewSizePoints).")
|
|
return .medium
|
|
}
|
|
// Determine render size for .scaleAspectFill.
|
|
let renderSizeByWidth = CGSize(width: viewSizePoints.width,
|
|
height: viewSizePoints.width * mediaSizePoints.height / mediaSizePoints.width)
|
|
let renderSizeByHeight = CGSize(width: viewSizePoints.height * mediaSizePoints.width / mediaSizePoints.height,
|
|
height: viewSizePoints.height)
|
|
let renderSizePoints = (renderSizeByWidth.width > renderSizeByHeight.width
|
|
? renderSizeByWidth
|
|
: renderSizeByHeight)
|
|
let renderDimensionPoints = renderSizePoints.largerAxis
|
|
let quality: AttachmentThumbnailQuality = {
|
|
// Find the smallest quality of acceptable size.
|
|
let qualities: [AttachmentThumbnailQuality] = [
|
|
.small,
|
|
.medium,
|
|
.mediumLarge
|
|
// Skip .large
|
|
]
|
|
for quality in qualities {
|
|
// The image will .scaleAspectFill the bounds of the media view.
|
|
// We want to ensure that we more-or-less have sufficient pixel
|
|
// data for the screen. There are only a few thumbnail sizes,
|
|
// so falling over to the next largest size is expensive. Therefore
|
|
// we include a small measure of slack in our calculation.
|
|
//
|
|
// targetQuality is expressed in terms of "the worst case ratio of
|
|
// image pixels per screen pixels that we will accept."
|
|
let targetQuality: CGFloat = 0.8
|
|
let sizeTolerance: CGFloat = 1 / targetQuality
|
|
let thumbnailDimensionPoints = quality.thumbnailDimensionPoints()
|
|
if renderDimensionPoints <= CGFloat(thumbnailDimensionPoints) * sizeTolerance {
|
|
return quality
|
|
}
|
|
}
|
|
return .large
|
|
}()
|
|
return quality
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct CVMediaAlbumItem: Equatable {
|
|
|
|
public let attachment: CVAttachment
|
|
|
|
// This property will only be set if the attachment is downloaded and valid.
|
|
public let attachmentStream: AttachmentStream?
|
|
|
|
public var renderingFlag: AttachmentReference.RenderingFlag {
|
|
attachment.attachment.reference.renderingFlag
|
|
}
|
|
|
|
public let hasCaption: Bool
|
|
|
|
// This property will be non-zero if the attachment is valid.
|
|
//
|
|
// TODO: Add units to name.
|
|
public let mediaSize: CGSize
|
|
|
|
public let isBroken: Bool
|
|
public var isFailedDownload: Bool {
|
|
switch attachment {
|
|
case .stream:
|
|
return false
|
|
case .pointer(_, let transitTierDownloadState):
|
|
return transitTierDownloadState == .failed
|
|
case .backupThumbnail:
|
|
// TODO[Backups]: Check state of media tier download
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Whether the containing thread has a pending message request
|
|
public let threadHasPendingMessageRequest: Bool
|
|
|
|
public static func == (lhs: CVMediaAlbumItem, rhs: CVMediaAlbumItem) -> Bool {
|
|
return lhs.attachment == rhs.attachment
|
|
&& lhs.hasCaption == rhs.hasCaption
|
|
&& lhs.mediaSize == rhs.mediaSize
|
|
&& lhs.isBroken == rhs.isBroken
|
|
&& lhs.threadHasPendingMessageRequest == rhs.threadHasPendingMessageRequest
|
|
}
|
|
}
|