TM-SGNL-iOS/Signal/ConversationView/Components/CVComponentViewOnce.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

437 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: AttachmentTransitPointer, renderingFlag: AttachmentReference.RenderingFlag)
case incomingFailed
case incomingPending
case incomingAvailable(attachmentStream: AttachmentStream, renderingFlag: AttachmentReference.RenderingFlag)
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),
(.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, _),
(.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 shouldShowIcon: Bool {
switch viewOnceState {
case .incomingInvalidContent, .incomingDownloading:
return false
default:
return true
}
}
private var shouldShowProgress: 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]()
switch viewOnceState {
case .incomingDownloading(let attachmentPointer, _):
let progressView = CVAttachmentProgressView(
direction: .download(
attachmentPointer: attachmentPointer,
transitTierDownloadState: .enqueuedOrDownloading
),
diameter: iconSize,
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache
)
subviews.append(progressView)
default:
if shouldShowIcon, let iconName = self.iconName {
let iconView = componentView.iconView
iconView.setTemplateImageName(iconName, tintColor: iconColor)
subviews.append(iconView)
}
}
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 iconSize: CGFloat = 24
private var downloadProgressRadius: CGFloat {
iconSize * 0.5
}
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 hasIcon = shouldShowIcon && iconName != nil
let hasIconOrProgress = hasIcon || shouldShowProgress
var availableWidth = maxWidth
if hasIconOrProgress {
availableWidth = max(0, availableWidth - (iconSize + stackViewConfig.spacing))
subviewInfos.append(CGSize.square(iconSize).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
public override 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.didTapFailedOrPendingDownloads(message)
case .incomingAvailable:
componentDelegate.didTapViewOnceAttachment(message)
case .incomingExpired, .outgoingSentExpired:
componentDelegate.didTapViewOnceExpired(message)
case .outgoingFailed,
.outgoingSending:
break
}
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: -
fileprivate extension CVComponentViewOnce {
var iconName: String? {
switch viewOnceState {
case .unknown:
owsFailDebug("Invalid value.")
return "view_once-dash"
case .incomingExpired:
return "view_once-dash"
case .incomingDownloading:
owsFailDebug("Unexpected state.")
return nil
case .incomingFailed, .incomingPending:
return "arrow-circle-down"
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:
return conversationStyle.bubbleTextColorIncoming
case .outgoingFailed,
.outgoingSending,
.outgoingSentExpired:
return conversationStyle.bubbleTextColorOutgoing
case .incomingInvalidContent:
return Theme.secondaryTextAndIconColor
}
}
var iconColor: UIColor {
let pendingColor: UIColor = (Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_gray75)
switch viewOnceState {
case .unknown:
owsFailDebug("Invalid value.")
return conversationStyle.bubbleTextColorIncoming
case .incomingExpired:
return conversationStyle.bubbleTextColorIncoming
case .incomingDownloading,
.incomingFailed,
.incomingPending:
return pendingColor
case .incomingAvailable:
return conversationStyle.bubbleTextColorIncoming
case .outgoingFailed:
return pendingColor
case .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 .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, .invalid, .audio:
owsFailDebug("Invalid view once type")
return .unknown
case .image:
return .photo
case .video:
return .video
case .animatedImage:
return .photo
}
case .unknown,
.incomingExpired,
.incomingDownloading,
.incomingFailed,
.incomingPending,
.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
}
}