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

450 lines
17 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
// TODO: This will be part of our reuse strategy.
// We'll probably want to have reuse identifiers
// that correspond to certain CVComponentState common
// variations, e.g.:
//
// * Text-only message with optional sender name + footer.
// * Media message with optional text + sender name + footer.
public enum CVCellReuseIdentifier: String, CaseIterable {
case `default`
case dateHeader
case unreadIndicator
case typingIndicator
case threadDetails
case systemMessage
case unknownThreadWarning
}
// MARK: -
// Represents a single item in the conversation history.
// Could be a date header or a unread indicator.
public protocol CVItemCell where Self: UICollectionViewCell {
var isCellVisible: Bool { get set }
}
// MARK: -
public class CVCell: UICollectionViewCell, CVItemCell, CVRootComponentHost {
public var isCellVisible: Bool = false {
didSet {
componentView?.setIsCellVisible(isCellVisible)
if isCellVisible {
guard let renderItem = renderItem,
let componentView = componentView,
let messageSwipeActionState = messageSwipeActionState else {
return
}
renderItem.rootComponent.cellDidBecomeVisible(componentView: componentView,
renderItem: renderItem,
messageSwipeActionState: messageSwipeActionState)
}
}
}
public var renderItem: CVRenderItem?
public var componentView: CVComponentView?
public var hostView: UIView { contentView }
public var rootComponent: CVRootComponent? { renderItem?.rootComponent }
private var messageSwipeActionState: CVMessageSwipeActionState?
override init(frame: CGRect) {
super.init(frame: frame)
layoutMargins = .zero
contentView.layoutMargins = .zero
}
@available(*, unavailable, message: "Unimplemented")
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public static func registerReuseIdentifiers(collectionView: UICollectionView) {
for value in CVCellReuseIdentifier.allCases {
collectionView.register(self, forCellWithReuseIdentifier: value.rawValue)
}
}
public override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return super.systemLayoutSizeFitting(targetSize)
}
let cellSize = renderItem.cellSize
if cellSize.width > targetSize.width || cellSize.height > targetSize.height {
// This can happen due to races or incorrect initial view size on iPad.
Logger.verbose("Unexpected cellSize: \(cellSize), targetSize: \(targetSize)")
}
return targetSize
}
public override func systemLayoutSizeFitting(_ targetSize: CGSize,
withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority,
verticalFittingPriority: UILayoutPriority) -> CGSize {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return super.systemLayoutSizeFitting(targetSize,
withHorizontalFittingPriority: horizontalFittingPriority,
verticalFittingPriority: verticalFittingPriority)
}
let cellSize = renderItem.cellSize
if cellSize.width > targetSize.width || cellSize.height > targetSize.height {
// This can happen due to races or incorrect initial view size on iPad.
Logger.verbose("Unexpected cellSize: \(cellSize), targetSize: \(targetSize)")
}
return targetSize
}
// For perf reasons, skip the default implementation which is only relevant for self-sizing cells.
public override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
layoutAttributes
}
private var lastLayoutAttributes: CVCollectionViewLayoutAttributes?
public override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
guard let layoutAttributes = layoutAttributes as? CVCollectionViewLayoutAttributes else {
owsFailDebug("Could not apply layoutAttributes.")
return
}
lastLayoutAttributes = layoutAttributes
applyLastLayoutAttributes()
}
func configure(renderItem: CVRenderItem,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) {
let isReusingDedicatedCell = componentView != nil && renderItem.rootComponent.isDedicatedCell
if !isReusingDedicatedCell {
layoutMargins = .zero
contentView.layoutMargins = .zero
}
configureForHosting(renderItem: renderItem,
componentDelegate: componentDelegate,
messageSwipeActionState: messageSwipeActionState)
self.messageSwipeActionState = messageSwipeActionState
applyLastLayoutAttributes()
}
private func applyLastLayoutAttributes() {
guard let layoutAttributes = self.lastLayoutAttributes else {
Logger.verbose("Missing layoutAttributes.")
return
}
// Insist that the cell honor its zIndex.
layer.zPosition = CGFloat(layoutAttributes.zIndex)
guard let rootComponent = self.rootComponent,
let componentView = self.componentView else {
return
}
rootComponent.apply(layoutAttributes: layoutAttributes,
componentView: componentView)
}
override public func prepareForReuse() {
super.prepareForReuse()
var isDedicatedCell = false
if let rootComponent = self.rootComponent {
isDedicatedCell = rootComponent.isDedicatedCell
} else {
owsFailDebug("Missing rootComponent.")
}
renderItem = nil
if !isDedicatedCell {
contentView.removeAllSubviews()
}
if let componentView = componentView {
componentView.reset()
} else {
owsFailDebug("Missing componentView.")
}
isCellVisible = false
messageSwipeActionState = nil
lastLayoutAttributes = nil
layer.zPosition = 0
}
public override func layoutSubviews() {
super.layoutSubviews()
guard let renderItem = renderItem,
let componentView = componentView,
let messageSwipeActionState = messageSwipeActionState else {
return
}
renderItem.rootComponent.cellDidLayoutSubviews(componentView: componentView,
renderItem: renderItem,
messageSwipeActionState: messageSwipeActionState)
}
}
// MARK: -
// This view hosts the cell contents.
// This allows us to display message cells outside of
// UICollectionView, e.g. in the message details view.
public class CVCellView: UIView, CVRootComponentHost {
public var isCellVisible: Bool = false {
didSet {
componentView?.setIsCellVisible(isCellVisible)
}
}
public var renderItem: CVRenderItem?
public var componentView: CVComponentView?
public var hostView: UIView { self }
public var rootComponent: CVRootComponent? { renderItem?.rootComponent }
init() {
super.init(frame: .zero)
}
public func configure(renderItem: CVRenderItem,
componentDelegate: CVComponentDelegate) {
self.layoutMargins = .zero
let messageSwipeActionState = CVMessageSwipeActionState()
configureForHosting(renderItem: renderItem,
componentDelegate: componentDelegate,
messageSwipeActionState: messageSwipeActionState)
owsAssertDebug(componentView != nil)
}
@available(*, unavailable, message: "Unimplemented")
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func reset() {
renderItem = nil
removeAllSubviews()
if let componentView = componentView {
componentView.reset()
}
}
}
// MARK: -
public protocol CVRootComponentHost: AnyObject {
var renderItem: CVRenderItem? { get set }
var componentView: CVComponentView? { get set }
var rootComponent: CVRootComponent? { get }
var hostView: UIView { get }
var isCellVisible: Bool { get }
}
// MARK: -
public extension CVRootComponentHost {
fileprivate func configureForHosting(renderItem: CVRenderItem,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) {
self.renderItem = renderItem
#if TESTABLE_BUILD
GRDBDatabaseStorageAdapter.canOpenTransaction = false
#endif
let rootComponent = renderItem.rootComponent
let componentView: CVComponentView
if let componentViewForReuse = self.componentView {
componentView = componentViewForReuse
} else {
componentView = rootComponent.buildComponentView(componentDelegate: componentDelegate)
}
self.componentView = componentView
componentView.setIsCellVisible(isCellVisible)
componentView.isDedicatedCellView = rootComponent.isDedicatedCell
rootComponent.configureCellRootComponent(cellView: hostView,
cellMeasurement: renderItem.cellMeasurement,
componentDelegate: componentDelegate,
messageSwipeActionState: messageSwipeActionState,
componentView: componentView)
#if TESTABLE_BUILD
GRDBDatabaseStorageAdapter.canOpenTransaction = true
#endif
}
func handleTap(sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate) -> Bool {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return false
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return false
}
return renderItem.rootComponent.handleTap(sender: sender,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem)
}
func canHandleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate) -> Bool {
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return false
}
// Can the _view_ handle the double tap?
guard componentView.canHandleDoubleTapGesture?(sender) == true else {
return false
}
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return false
}
// Can the _contents_ handle the double tap?
return renderItem.rootComponent.canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem)
}
func handleDoubleTap(sender: UIGestureRecognizer, componentDelegate: any CVComponentDelegate) -> Bool {
guard canHandleDoubleTap(sender: sender, componentDelegate: componentDelegate) else {
return false
}
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return false
}
return renderItem.rootComponent.handleDoubleTap(sender: sender, componentDelegate: componentDelegate, renderItem: renderItem)
}
func findLongPressHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate) -> CVLongPressHandler? {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return nil
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return nil
}
return renderItem.rootComponent.findLongPressHandler(sender: sender,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem)
}
func findPanHandler(sender: UIPanGestureRecognizer,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) -> CVPanHandler? {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return nil
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return nil
}
return renderItem.rootComponent.findPanHandler(sender: sender,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem,
messageSwipeActionState: messageSwipeActionState)
}
func startPanGesture(sender: UIPanGestureRecognizer,
panHandler: CVPanHandler,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return
}
renderItem.rootComponent.startPanGesture(sender: sender,
panHandler: panHandler,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem,
messageSwipeActionState: messageSwipeActionState)
}
func handlePanGesture(sender: UIPanGestureRecognizer,
panHandler: CVPanHandler,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return
}
renderItem.rootComponent.handlePanGesture(sender: sender,
panHandler: panHandler,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem,
messageSwipeActionState: messageSwipeActionState)
}
func albumItemView(forAttachment attachment: ReferencedAttachment) -> UIView? {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return nil
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return nil
}
guard let messageComponent = renderItem.rootComponent as? CVComponentMessage else {
owsFailDebug("Invalid rootComponent.")
return nil
}
return messageComponent.albumItemView(forAttachment: attachment,
componentView: componentView)
}
func updateScrollingContent() {
guard let rootComponent = rootComponent,
let componentView = componentView else {
owsFailDebug("Missing component.")
return
}
rootComponent.updateScrollingContent(componentView: componentView)
}
}