448 lines
15 KiB
Swift
448 lines
15 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
enum ViewOnceState: Equatable {
|
|
case unknown
|
|
case incomingExpired
|
|
case incomingDownloading(attachmentPointer: AttachmentPointer, renderingFlag: AttachmentReference.RenderingFlag)
|
|
case incomingFailed
|
|
case incomingPending
|
|
case incomingAvailable(attachmentStream: AttachmentStream, renderingFlag: AttachmentReference.RenderingFlag)
|
|
case incomingUndownloadable
|
|
case incomingInvalidContent
|
|
case outgoingSending
|
|
case outgoingFailed
|
|
case outgoingSentExpired
|
|
|
|
static func ==(lhs: ViewOnceState, rhs: ViewOnceState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case
|
|
(.unknown, .unknown),
|
|
(.incomingExpired, .incomingExpired),
|
|
(.incomingFailed, .incomingFailed),
|
|
(.incomingPending, .incomingPending),
|
|
(.incomingUndownloadable, .incomingUndownloadable),
|
|
(.incomingInvalidContent, .incomingInvalidContent),
|
|
(.outgoingSending, .outgoingSending),
|
|
(.outgoingFailed, .outgoingFailed),
|
|
(.outgoingSentExpired, .outgoingSentExpired):
|
|
return true
|
|
case let (.incomingDownloading(lhsPointer, lhsFlag), .incomingDownloading(rhsPointer, rhsFlag)):
|
|
return lhsPointer.id == rhsPointer.id
|
|
&& lhsFlag == rhsFlag
|
|
case let (.incomingAvailable(lhsStream, lhsFlag), .incomingAvailable(rhsStream, rhsFlag)):
|
|
return lhsStream.id == rhsStream.id
|
|
&& lhsFlag == rhsFlag
|
|
case
|
|
(.unknown, _),
|
|
(.incomingExpired, _),
|
|
(.incomingFailed, _),
|
|
(.incomingPending, _),
|
|
(.incomingUndownloadable, _),
|
|
(.incomingInvalidContent, _),
|
|
(.outgoingSending, _),
|
|
(.outgoingFailed, _),
|
|
(.outgoingSentExpired, _),
|
|
(.incomingDownloading, _),
|
|
(.incomingAvailable, _):
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public class CVComponentViewOnce: CVComponentBase, CVComponent {
|
|
|
|
public var componentKey: CVComponentKey { .viewOnce }
|
|
|
|
private enum ViewOnceMessageType: Equatable {
|
|
case unknown
|
|
case photo
|
|
case video
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private let viewOnce: CVComponentState.ViewOnce
|
|
private var viewOnceState: ViewOnceState {
|
|
viewOnce.viewOnceState
|
|
}
|
|
|
|
private var isExpired: Bool {
|
|
switch viewOnce.viewOnceState {
|
|
case .incomingExpired, .outgoingSentExpired:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var attachmentStream: AttachmentStream? {
|
|
if case .incomingAvailable(let attachmentStream, _) = viewOnceState {
|
|
return attachmentStream
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var shouldShowStateIcon: Bool {
|
|
switch viewOnceState {
|
|
case .incomingInvalidContent, .incomingDownloading:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var shouldShowProgressView: Bool {
|
|
switch viewOnceState {
|
|
case .incomingDownloading:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
init(itemModel: CVItemModel, viewOnce: CVComponentState.ViewOnce) {
|
|
self.viewOnce = viewOnce
|
|
|
|
super.init(itemModel: itemModel)
|
|
}
|
|
|
|
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
|
|
CVComponentViewViewOnce()
|
|
}
|
|
|
|
public func configureForRendering(
|
|
componentView componentViewParam: CVComponentView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
) {
|
|
guard let componentView = componentViewParam as? CVComponentViewViewOnce else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
componentViewParam.reset()
|
|
return
|
|
}
|
|
|
|
var subviews = [UIView]()
|
|
|
|
let configuration = CVAttachmentProgressView.Configuration(
|
|
conversationStyle: conversationStyle,
|
|
isIncoming: isIncoming,
|
|
margin: 3,
|
|
)
|
|
|
|
switch viewOnceState {
|
|
case .incomingDownloading(let attachmentPointer, _):
|
|
let progressView = CVAttachmentProgressView(
|
|
direction: .download(
|
|
attachmentPointer: attachmentPointer,
|
|
downloadState: .enqueuedOrDownloading,
|
|
),
|
|
configuration: configuration,
|
|
)
|
|
subviews.append(progressView)
|
|
default:
|
|
if shouldShowStateIcon, let iconName {
|
|
let backgroundCirleView = CVAttachmentProgressView.circularBackgroundView(
|
|
configuration: configuration,
|
|
)
|
|
let iconView = componentView.iconView
|
|
iconView.contentMode = .center
|
|
iconView.setTemplateImageName(iconName, tintColor: configuration.foregroundColor)
|
|
backgroundCirleView.addSubviewToFillSuperviewEdges(iconView)
|
|
subviews.append(backgroundCirleView)
|
|
}
|
|
}
|
|
|
|
let label = componentView.label
|
|
labelConfig.applyForRendering(label: label)
|
|
subviews.append(label)
|
|
|
|
let stackView = componentView.stackView
|
|
stackView.reset()
|
|
stackView.configure(
|
|
config: stackViewConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_stackView,
|
|
subviews: subviews,
|
|
)
|
|
}
|
|
|
|
private let stateViewSize = CGSize(square: 40)
|
|
|
|
private var stackViewConfig: CVStackViewConfig {
|
|
CVStackViewConfig(
|
|
axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 8,
|
|
layoutMargins: .zero,
|
|
)
|
|
}
|
|
|
|
private static let measurementKey_stackView = "CVComponentViewOnce.measurementKey_stackView"
|
|
|
|
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
owsAssertDebug(maxWidth > 0)
|
|
|
|
var subviewInfos = [ManualStackSubviewInfo]()
|
|
|
|
let showStateIcon = shouldShowStateIcon && iconName != nil
|
|
|
|
var availableWidth = maxWidth
|
|
if showStateIcon || shouldShowProgressView {
|
|
availableWidth = max(0, availableWidth - (stateViewSize.width + stackViewConfig.spacing))
|
|
subviewInfos.append(stateViewSize.asManualSubviewInfo(hasFixedSize: true))
|
|
}
|
|
let textSize = CVText.measureLabel(config: labelConfig, maxWidth: availableWidth)
|
|
subviewInfos.append(textSize.asManualSubviewInfo)
|
|
|
|
let stackMeasurement = ManualStackView.measure(
|
|
config: stackViewConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_stackView,
|
|
subviewInfos: subviewInfos,
|
|
maxWidth: maxWidth,
|
|
)
|
|
var result = stackMeasurement.measuredSize
|
|
// We use this "min width" to reduce/avoid "flutter"
|
|
// in the bubble's size as the message changes states.
|
|
let minContentWidth: CGFloat = maxWidth * 0.4
|
|
result.width = max(result.width, minContentWidth)
|
|
return result
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
override public func handleTap(
|
|
sender: UIGestureRecognizer,
|
|
componentDelegate: CVComponentDelegate,
|
|
componentView: CVComponentView,
|
|
renderItem: CVRenderItem,
|
|
) -> Bool {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let message = interaction as? TSMessage else {
|
|
owsFailDebug("Invalid interaction.")
|
|
return false
|
|
}
|
|
|
|
switch viewOnceState {
|
|
case .unknown:
|
|
owsFailDebug("Invalid value.")
|
|
case .incomingDownloading,
|
|
.incomingInvalidContent:
|
|
break
|
|
case .incomingFailed, .incomingPending:
|
|
componentDelegate.didTapSkippedDownloads(message)
|
|
case .incomingAvailable:
|
|
componentDelegate.didTapViewOnceAttachment(message)
|
|
case .incomingExpired, .outgoingSentExpired:
|
|
componentDelegate.didTapViewOnceExpired(message)
|
|
case .outgoingFailed,
|
|
.outgoingSending:
|
|
break
|
|
case .incomingUndownloadable:
|
|
componentDelegate.didTapUndownloadableMedia()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// Used for rendering some portion of an Conversation View item.
|
|
// It could be the entire item or some part thereof.
|
|
public class CVComponentViewViewOnce: NSObject, CVComponentView {
|
|
|
|
fileprivate let stackView = ManualStackView(name: "viewOnce")
|
|
fileprivate let iconView = CVImageView()
|
|
fileprivate let label = CVLabel()
|
|
|
|
public var isDedicatedCellView = false
|
|
|
|
public var rootView: UIView {
|
|
stackView
|
|
}
|
|
|
|
public func setIsCellVisible(_ isCellVisible: Bool) {}
|
|
|
|
public func reset() {
|
|
stackView.reset()
|
|
iconView.image = nil
|
|
label.text = nil
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension CVComponentViewOnce {
|
|
var iconName: String? {
|
|
switch viewOnceState {
|
|
case .unknown:
|
|
owsFailDebug("Invalid value.")
|
|
return "view_once-dash"
|
|
case .incomingExpired, .incomingUndownloadable:
|
|
return "view_once-dash"
|
|
case .incomingDownloading:
|
|
owsFailDebug("Unexpected state.")
|
|
return nil
|
|
case .incomingFailed, .incomingPending:
|
|
return Theme.iconName(.arrowDown)
|
|
case .incomingAvailable:
|
|
return "view_once"
|
|
case .outgoingFailed:
|
|
return "refresh"
|
|
case .outgoingSending,
|
|
.outgoingSentExpired:
|
|
return "view_once-dash"
|
|
case .incomingInvalidContent:
|
|
owsFailDebug("Unexpected state.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var textColor: UIColor {
|
|
switch viewOnceState {
|
|
case .unknown:
|
|
owsFailDebug("Invalid value.")
|
|
return conversationStyle.bubbleTextColorIncoming
|
|
case .incomingExpired,
|
|
.incomingDownloading,
|
|
.incomingFailed,
|
|
.incomingPending,
|
|
.incomingAvailable,
|
|
.incomingUndownloadable:
|
|
return conversationStyle.bubbleTextColorIncoming
|
|
case .outgoingFailed,
|
|
.outgoingSending,
|
|
.outgoingSentExpired:
|
|
return conversationStyle.bubbleTextColorOutgoing
|
|
case .incomingInvalidContent:
|
|
return Theme.secondaryTextAndIconColor
|
|
}
|
|
}
|
|
|
|
var labelConfig: CVLabelConfig {
|
|
func buildDefaultConfig(text: String) -> CVLabelConfig {
|
|
return CVLabelConfig.unstyledText(
|
|
text,
|
|
font: UIFont.dynamicTypeSubheadline.semibold(),
|
|
textColor: textColor,
|
|
numberOfLines: 1,
|
|
lineBreakMode: .byTruncatingTail,
|
|
)
|
|
}
|
|
|
|
switch viewOnceState {
|
|
case .unknown:
|
|
owsFailDebug("Invalid value.")
|
|
return buildDefaultConfig(text: CommonStrings.genericError)
|
|
case .incomingExpired:
|
|
let text = OWSLocalizedString(
|
|
"PER_MESSAGE_EXPIRATION_VIEWED",
|
|
comment: "Label for view-once messages indicating that the local user has viewed the message's contents.",
|
|
)
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingUndownloadable:
|
|
let text = OWSLocalizedString(
|
|
"PER_MESSAGE_EXPIRATION_EXPIRED",
|
|
comment: "Label for view-once messages indicating that the message's contents are expired and unavailable to download.",
|
|
)
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingDownloading:
|
|
let text = OWSLocalizedString("MESSAGE_STATUS_DOWNLOADING", comment: "message status while message is downloading.")
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingFailed:
|
|
let text = CommonStrings.retryButton
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingPending:
|
|
let text = OWSLocalizedString("ACTION_TAP_TO_DOWNLOAD", comment: "A label for 'tap to download' buttons.")
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingAvailable:
|
|
let text: String
|
|
switch viewOnceMessageType {
|
|
case .photo:
|
|
text = MessageStrings.viewOnceViewPhoto
|
|
case .video:
|
|
text = MessageStrings.viewOnceViewVideo
|
|
case .unknown:
|
|
owsFailDebug("unexpected viewOnceMessageType for IncomingFailed.")
|
|
text = MessageStrings.viewOnceViewPhoto
|
|
}
|
|
return buildDefaultConfig(text: text)
|
|
case .outgoingFailed:
|
|
let text = CommonStrings.retryButton
|
|
return buildDefaultConfig(text: text)
|
|
case .outgoingSending,
|
|
.outgoingSentExpired:
|
|
let text = OWSLocalizedString(
|
|
"PER_MESSAGE_EXPIRATION_OUTGOING_MESSAGE",
|
|
comment: "Label for outgoing view-once messages.",
|
|
)
|
|
return buildDefaultConfig(text: text)
|
|
case .incomingInvalidContent:
|
|
let text = OWSLocalizedString(
|
|
"PER_MESSAGE_EXPIRATION_INVALID_CONTENT",
|
|
comment: "Label for view-once messages that have invalid content.",
|
|
)
|
|
// Reconfigure label for this state only.
|
|
return CVLabelConfig.unstyledText(
|
|
text,
|
|
font: UIFont.dynamicTypeSubheadline,
|
|
textColor: Theme.secondaryTextAndIconColor,
|
|
numberOfLines: 0,
|
|
lineBreakMode: .byWordWrapping,
|
|
)
|
|
}
|
|
}
|
|
|
|
private var viewOnceMessageType: ViewOnceMessageType {
|
|
switch viewOnceState {
|
|
case let .incomingAvailable(attachmentStream, _):
|
|
switch attachmentStream.contentType {
|
|
case .file, .audio:
|
|
owsFailDebug("Invalid view once type")
|
|
return .unknown
|
|
case .image:
|
|
return .photo
|
|
case .video:
|
|
return .video
|
|
}
|
|
case .unknown,
|
|
.incomingExpired,
|
|
.incomingDownloading,
|
|
.incomingFailed,
|
|
.incomingPending,
|
|
.incomingUndownloadable,
|
|
.incomingInvalidContent,
|
|
.outgoingSending,
|
|
.outgoingFailed,
|
|
.outgoingSentExpired:
|
|
// The attachment doesn't exist for outgoing
|
|
// messages so we'd need to store the content type if
|
|
// we wanted to distinguish between photo and video
|
|
|
|
// For incoming messages viewed messages, it doesn't matter
|
|
// because we show generic "View" text, regardless of the
|
|
// content type
|
|
return .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension CVComponentViewOnce: CVAccessibilityComponent {
|
|
public var accessibilityDescription: String {
|
|
// TODO: We could include the media type (video, image, animated image).
|
|
labelConfig.text.accessibilityDescription
|
|
}
|
|
}
|