839 lines
37 KiB
Swift
839 lines
37 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
@objc
|
|
public class HomeViewCell: UITableViewCell {
|
|
|
|
@objc
|
|
public static let reuseIdentifier = "HomeViewCell"
|
|
|
|
private let avatarView = ConversationAvatarView(diameterPoints: HomeViewCell.avatarSize,
|
|
localUserDisplayMode: .noteToSelf,
|
|
shouldLoadAsync: false)
|
|
|
|
private let nameLabel = CVLabel()
|
|
private let snippetLabel = CVLabel()
|
|
private let dateTimeLabel = CVLabel()
|
|
private let messageStatusIconView = CVImageView()
|
|
private let typingIndicatorView = TypingIndicatorView()
|
|
private let muteIconView = CVImageView()
|
|
|
|
private let unreadBadge = NeverClearView(name: "unreadBadge")
|
|
private let unreadLabel = CVLabel()
|
|
|
|
private let outerHStack = ManualStackViewWithLayer(name: "outerHStack")
|
|
private let avatarStack = ManualStackView(name: "avatarStack")
|
|
private let vStack = ManualStackView(name: "vStack")
|
|
private let topRowStack = ManualStackView(name: "topRowStack")
|
|
private let bottomRowStack = ManualStackView(name: "bottomRowStack")
|
|
// The "Wrapper" shows either "snippet label" or "typing indicator".
|
|
private let bottomRowWrapper = ManualLayoutView(name: "bottomRowWrapper")
|
|
|
|
private var cvviews: [CVView] {
|
|
[
|
|
avatarView,
|
|
nameLabel,
|
|
snippetLabel,
|
|
dateTimeLabel,
|
|
messageStatusIconView,
|
|
typingIndicatorView,
|
|
muteIconView,
|
|
unreadLabel,
|
|
|
|
outerHStack,
|
|
avatarStack,
|
|
vStack,
|
|
topRowStack,
|
|
bottomRowStack,
|
|
bottomRowWrapper
|
|
]
|
|
}
|
|
|
|
public struct Configuration {
|
|
let thread: ThreadViewModel
|
|
let shouldLoadAvatarAsync: Bool
|
|
let isBlocked: Bool
|
|
let overrideSnippet: NSAttributedString?
|
|
let overrideDate: Date?
|
|
let cellMeasurementCache: LRUCache<String, HVCellMeasurement>
|
|
|
|
public init(thread: ThreadViewModel,
|
|
shouldLoadAvatarAsync: Bool,
|
|
isBlocked: Bool,
|
|
overrideSnippet: NSAttributedString? = nil,
|
|
overrideDate: Date? = nil,
|
|
cellMeasurementCache: LRUCache<String, HVCellMeasurement>) {
|
|
self.thread = thread
|
|
self.shouldLoadAvatarAsync = shouldLoadAvatarAsync
|
|
self.isBlocked = isBlocked
|
|
self.overrideSnippet = overrideSnippet
|
|
self.overrideDate = overrideDate
|
|
self.cellMeasurementCache = cellMeasurementCache
|
|
}
|
|
}
|
|
private var configuration: Configuration?
|
|
|
|
private var thread: ThreadViewModel? {
|
|
configuration?.thread
|
|
}
|
|
private var overrideSnippet: NSAttributedString? {
|
|
configuration?.overrideSnippet
|
|
}
|
|
private var hasOverrideSnippet: Bool {
|
|
overrideSnippet != nil
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private var unreadFont: UIFont {
|
|
UIFont.ows_dynamicTypeCaption1Clamped.ows_semibold
|
|
}
|
|
|
|
private var dateTimeFont: UIFont {
|
|
.ows_dynamicTypeCaption1Clamped
|
|
}
|
|
|
|
private var snippetFont: UIFont {
|
|
.ows_dynamicTypeSubheadlineClamped
|
|
}
|
|
|
|
private var nameFont: UIFont {
|
|
UIFont.ows_dynamicTypeBodyClamped.ows_semibold
|
|
}
|
|
|
|
// Used for profile names.
|
|
private var nameSecondaryFont: UIFont {
|
|
UIFont.ows_dynamicTypeBodyClamped.ows_italic
|
|
}
|
|
|
|
private var snippetColor: UIColor {
|
|
Theme.isDarkThemeEnabled ? .ows_gray25 : .ows_gray45
|
|
}
|
|
|
|
// This value is now larger than AvatarBuilder.standardAvatarSizePoints.
|
|
private static let avatarSize: UInt = 56
|
|
|
|
// MARK: -
|
|
|
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
|
|
|
commonInit()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func commonInit() {
|
|
self.backgroundColor = Theme.backgroundColor
|
|
|
|
contentView.addSubview(outerHStack)
|
|
outerHStack.shouldDeactivateConstraints = false
|
|
outerHStack.autoPinEdge(toSuperviewEdge: .leading)
|
|
outerHStack.autoPinTrailingToSuperviewMargin()
|
|
outerHStack.autoPinHeightToSuperview()
|
|
|
|
self.selectionStyle = .default
|
|
}
|
|
|
|
public func configure(_ configuration: Configuration) {
|
|
AssertIsOnMainThread()
|
|
|
|
// If we have an existing HVCellMeasurement, use it.
|
|
// Cell measurement/arrangement is expensive.
|
|
let cacheKey = configuration.thread.threadRecord.uniqueId
|
|
let cellMeasurementCache = configuration.cellMeasurementCache
|
|
let existingCellMeasurement: HVCellMeasurement? = cellMeasurementCache.get(key: cacheKey)
|
|
let cellMeasurement = configure(configuration: configuration,
|
|
existingCellMeasurement: existingCellMeasurement)
|
|
cellMeasurementCache.set(key: cacheKey, value: cellMeasurement)
|
|
}
|
|
|
|
fileprivate struct Configs {
|
|
let topRowStackConfig: ManualStackView.Config
|
|
let bottomRowStackConfig: ManualStackView.Config
|
|
let vStackConfig: ManualStackView.Config
|
|
let outerHStackConfig: ManualStackView.Config
|
|
let avatarStackConfig: ManualStackView.Config
|
|
let snippetLabelConfig: CVLabelConfig
|
|
let nameLabelConfig: CVLabelConfig
|
|
let dateTimeLabelConfig: CVLabelConfig
|
|
}
|
|
|
|
private func configure(configuration: Configuration,
|
|
existingCellMeasurement: HVCellMeasurement?) -> HVCellMeasurement {
|
|
AssertIsOnMainThread()
|
|
|
|
OWSTableItem.configureCell(self)
|
|
|
|
self.preservesSuperviewLayoutMargins = false
|
|
self.contentView.preservesSuperviewLayoutMargins = false
|
|
|
|
self.configuration = configuration
|
|
|
|
let thread = configuration.thread
|
|
let isBlocked = configuration.isBlocked
|
|
|
|
let configs: Configs
|
|
if let existingCellMeasurement = existingCellMeasurement {
|
|
configs = existingCellMeasurement.configs
|
|
} else {
|
|
configs = Configs(
|
|
topRowStackConfig: self.topRowStackConfig,
|
|
bottomRowStackConfig: self.bottomRowStackConfig,
|
|
vStackConfig: self.vStackConfig,
|
|
outerHStackConfig: self.outerHStackConfig,
|
|
avatarStackConfig: ManualStackView.Config(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: UIEdgeInsets(hMargin: 0, vMargin: 12)),
|
|
snippetLabelConfig: self.snippetLabelConfig(configuration: configuration),
|
|
nameLabelConfig: self.nameLabelConfig(configuration: configuration),
|
|
dateTimeLabelConfig: self.dateTimeLabelConfig(configuration: configuration)
|
|
)
|
|
}
|
|
let topRowStackConfig = configs.topRowStackConfig
|
|
let bottomRowStackConfig = configs.bottomRowStackConfig
|
|
let vStackConfig = configs.vStackConfig
|
|
let outerHStackConfig = configs.outerHStackConfig
|
|
let avatarStackConfig = configs.avatarStackConfig
|
|
let snippetLabelConfig = configs.snippetLabelConfig
|
|
let nameLabelConfig = configs.nameLabelConfig
|
|
let dateTimeLabelConfig = configs.dateTimeLabelConfig
|
|
|
|
snippetLabelConfig.applyForRendering(label: snippetLabel)
|
|
// Reserve space for two lines of snippet text, taking into account
|
|
// the worst-case snippet content.
|
|
let snippetLineHeight = CGFloat(ceil(snippetFont.ows_semibold.lineHeight * 1.2))
|
|
|
|
avatarView.shouldLoadAsync = configuration.shouldLoadAvatarAsync
|
|
avatarView.configureWithSneakyTransaction(thread: thread.threadRecord)
|
|
|
|
typingIndicatorView.configureForHomeView()
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(otherUsersProfileDidChange(notification:)),
|
|
name: .otherUsersProfileDidChange,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(typingIndicatorStateDidChange),
|
|
name: TypingIndicatorsImpl.typingIndicatorStateDidChange,
|
|
object: nil)
|
|
|
|
// Avatar
|
|
|
|
let avatarSize: CGSize = .square(CGFloat(HomeViewCell.avatarSize))
|
|
|
|
// Unread Indicator
|
|
|
|
// If there are unread messages, show the "unread badge."
|
|
var shouldHideStatusIndicator = false
|
|
func applyUnreadIndicator() {
|
|
guard !hasOverrideSnippet else {
|
|
// If we're using the conversation list cell to render search results,
|
|
// don't show "unread badge" or "message status" indicator.
|
|
shouldHideStatusIndicator = true
|
|
return
|
|
}
|
|
guard hasUnreadStyle else {
|
|
return
|
|
}
|
|
|
|
let unreadCount = thread.unreadCount
|
|
let unreadLabel = self.unreadLabel
|
|
unreadLabel.text = unreadCount > 0 ? OWSFormat.formatUInt(unreadCount) : ""
|
|
unreadLabel.textColor = .ows_white
|
|
unreadLabel.lineBreakMode = .byTruncatingTail
|
|
unreadLabel.textAlignment = .center
|
|
unreadLabel.font = unreadFont
|
|
unreadLabel.removeFromSuperview()
|
|
let unreadLabelSize = unreadLabel.sizeThatFits(.square(.greatestFiniteMagnitude))
|
|
|
|
let unreadBadge = self.unreadBadge
|
|
unreadBadge.backgroundColor = .ows_accentBlue
|
|
unreadBadge.addSubview(unreadLabel) { view in
|
|
// Center within badge.
|
|
unreadLabel.frame = CGRect(origin: (view.frame.size - unreadLabelSize).asPoint * 0.5,
|
|
size: unreadLabelSize)
|
|
}
|
|
|
|
let unreadBadgeHeight = ceil(unreadLabel.font.lineHeight * 1.5)
|
|
unreadBadge.layer.cornerRadius = unreadBadgeHeight / 2
|
|
unreadBadge.layer.borderColor = Theme.backgroundColor.cgColor
|
|
unreadBadge.layer.borderWidth = 2
|
|
// This is a bit arbitrary, but it should scale with the size of dynamic text
|
|
let minMargin = CeilEven(unreadBadgeHeight * 0.5)
|
|
// Pill should be at least circular; can be wider.
|
|
let unreadBadgeSize = CGSize(width: max(unreadBadgeHeight,
|
|
unreadLabelSize.width + minMargin),
|
|
height: unreadBadgeHeight)
|
|
avatarStack.addSubview(unreadBadge) { view in
|
|
unreadBadge.frame = CGRect(origin: CGPoint(x: view.width - unreadBadgeSize.width + 6,
|
|
y: (view.height - avatarSize.height) * 0.5),
|
|
size: unreadBadgeSize)
|
|
}
|
|
// The unread badge should appear on top of the avatar, but it is added
|
|
// to the view hierarchy first (in the "configure" below where we leverage
|
|
// existing measurement/arrangements). We solve that by nudging the zPosition
|
|
// of the unread badge.
|
|
unreadBadge.layer.zPosition = +1
|
|
}
|
|
applyUnreadIndicator()
|
|
|
|
// The top row contains:
|
|
//
|
|
// * Name label
|
|
// * Mute icon (optional)
|
|
// * (spacing)
|
|
// * Date/Time label (fixed width)
|
|
//
|
|
// If there's "overflow" (not enough space to render the entire name)
|
|
// The name label should compress.
|
|
//
|
|
// If there's "underflow" (extra space in the layout) it should appear
|
|
// before the date/time label.
|
|
//
|
|
// The catch is that mute icon should "hug" the name label, so the
|
|
// name label can't expand to occupt any underflow in the layout.
|
|
var topRowStackSubviews = [UIView]()
|
|
|
|
nameLabelConfig.applyForRendering(label: nameLabel)
|
|
topRowStackSubviews.append(nameLabel)
|
|
|
|
let muteIconSize: CGFloat = 16
|
|
if shouldShowMuteIndicator(forThread: thread, isBlocked: isBlocked) {
|
|
muteIconView.setTemplateImageName("bell-disabled-outline-24",
|
|
tintColor: Theme.primaryTextColor)
|
|
muteIconView.tintColor = snippetColor
|
|
topRowStackSubviews.append(muteIconView)
|
|
}
|
|
|
|
dateTimeLabelConfig.applyForRendering(label: dateTimeLabel)
|
|
topRowStackSubviews.append(dateTimeLabel)
|
|
|
|
// The bottom row layout is also complicated because we want to be able to
|
|
// show/hide the typing indicator without reloading the cell. And we need
|
|
// to switch between them without any "jitter" in the layout.
|
|
//
|
|
// The "Wrapper" shows either "snippet label" or "typing indicator".
|
|
bottomRowWrapper.addSubview(snippetLabel) { [weak self] view in
|
|
guard let self = self else { return }
|
|
// Top-align the snippet text.
|
|
let snippetSize = self.snippetLabel.sizeThatFits(view.bounds.size)
|
|
if DebugFlags.internalLogging,
|
|
snippetSize.height > snippetLineHeight * 2 {
|
|
owsFailDebug("view: \(view.bounds.size), snippetSize: \(snippetSize), snippetLineHeight: \(snippetLineHeight), snippetLabelConfig: \(snippetLabelConfig.stringValue)")
|
|
}
|
|
let snippetFrame = CGRect(x: 0,
|
|
y: 0,
|
|
width: view.width,
|
|
height: min(view.bounds.height, ceil(snippetSize.height)))
|
|
self.snippetLabel.frame = snippetFrame
|
|
}
|
|
let typingIndicatorSize = TypingIndicatorView.measurement().measuredSize
|
|
bottomRowWrapper.addSubview(typingIndicatorView) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
// Vertically align the typing indicator with the first line of the snippet label.
|
|
self.typingIndicatorView.frame = CGRect(x: 0,
|
|
y: (snippetLineHeight - typingIndicatorSize.height) * 0.5,
|
|
width: typingIndicatorSize.width,
|
|
height: typingIndicatorSize.height)
|
|
}
|
|
// Use a fixed size for the snippet label and its wrapper.
|
|
let bottomRowWrapperSize = CGSize(width: 0, height: snippetLineHeight * 2)
|
|
|
|
var bottomRowStackSubviews: [UIView] = [ bottomRowWrapper ]
|
|
var bottomRowStackSubviewInfos: [ManualStackSubviewInfo] = [
|
|
bottomRowWrapperSize.asManualSubviewInfo()
|
|
]
|
|
if let statusIndicator = prepareStatusIndicatorView(thread: thread,
|
|
shouldHideStatusIndicator: shouldHideStatusIndicator) {
|
|
bottomRowStackSubviews.append(statusIndicator.view)
|
|
// The status indicator should vertically align with the
|
|
// first line of the snippet.
|
|
let locationOffset = CGPoint(x: 0,
|
|
y: snippetLineHeight * -0.5)
|
|
bottomRowStackSubviewInfos.append(statusIndicator.size.asManualSubviewInfo(hasFixedSize: true,
|
|
locationOffset: locationOffset))
|
|
}
|
|
|
|
updateTypingIndicatorState()
|
|
|
|
let avatarStackSubviews = [ avatarView ]
|
|
let vStackSubviews = [ topRowStack, bottomRowStack ]
|
|
let outerHStackSubviews = [ avatarStack, vStack ]
|
|
|
|
// Perf matters in home view. Configuring home view cells is
|
|
// probably the biggest perf bottleneck. In conversation view,
|
|
// we address this by doing cell measurement/arrangement off
|
|
// the main thread. That's viable in conversation view because
|
|
// there's a "load window" so there's an upper bound on how
|
|
// many cells need to be prepared.
|
|
//
|
|
// Home view has no load window. Therefore, home view defers
|
|
// the expensive work of a) building ThreadViewModel
|
|
// b) measurement/arrangement of cells. threadViewModelCache
|
|
// caches a). cellMeasurementCache caches b).
|
|
//
|
|
// When configuring a hohome view cell, we reuse any existing
|
|
// cell measurement in cellMeasurementCache. If none exists,
|
|
// we build one and store it in cellMeasurementCache for next
|
|
// time.
|
|
//
|
|
// This requires an unusual arrangement of the code in this
|
|
// configuration method for home view cells, where we defer
|
|
// all measurement logic here (below) so that we can skip it
|
|
// if it's not necessary.
|
|
if let existingCellMeasurement = existingCellMeasurement {
|
|
avatarStack.configure(config: avatarStackConfig,
|
|
measurement: existingCellMeasurement.avatarStackMeasurement,
|
|
subviews: avatarStackSubviews)
|
|
|
|
topRowStack.configure(config: topRowStackConfig,
|
|
measurement: existingCellMeasurement.topRowStackMeasurement,
|
|
subviews: topRowStackSubviews)
|
|
|
|
bottomRowStack.configure(config: bottomRowStackConfig,
|
|
measurement: existingCellMeasurement.bottomRowStackMeasurement,
|
|
subviews: bottomRowStackSubviews)
|
|
|
|
vStack.configure(config: vStackConfig,
|
|
measurement: existingCellMeasurement.vStackMeasurement,
|
|
subviews: vStackSubviews)
|
|
|
|
outerHStack.configure(config: outerHStackConfig,
|
|
measurement: existingCellMeasurement.outerHStackMeasurement,
|
|
subviews: outerHStackSubviews)
|
|
|
|
return existingCellMeasurement
|
|
} else {
|
|
var topRowStackSubviewInfos = [ManualStackSubviewInfo]()
|
|
let nameLabelSize = CVText.measureLabel(config: nameLabelConfig,
|
|
maxWidth: .greatestFiniteMagnitude)
|
|
topRowStackSubviewInfos.append(nameLabelSize.asManualSubviewInfo(horizontalFlowBehavior: .canCompress,
|
|
verticalFlowBehavior: .fixed))
|
|
if shouldShowMuteIndicator(forThread: thread, isBlocked: isBlocked) {
|
|
topRowStackSubviewInfos.append(CGSize(square: muteIconSize).asManualSubviewInfo(hasFixedSize: true))
|
|
}
|
|
let dateLabelSize = CVText.measureLabel(config: dateTimeLabelConfig,
|
|
maxWidth: CGFloat.greatestFiniteMagnitude)
|
|
topRowStackSubviewInfos.append(dateLabelSize.asManualSubviewInfo(horizontalFlowBehavior: .canExpand,
|
|
verticalFlowBehavior: .fixed))
|
|
|
|
let avatarStackMeasurement = avatarStack.configure(config: avatarStackConfig,
|
|
subviews: avatarStackSubviews,
|
|
subviewInfos: [ avatarSize.asManualSubviewInfo(hasFixedSize: true) ])
|
|
let avatarStackSize = avatarStackMeasurement.measuredSize
|
|
|
|
let topRowStackMeasurement = topRowStack.configure(config: topRowStackConfig,
|
|
subviews: topRowStackSubviews,
|
|
subviewInfos: topRowStackSubviewInfos)
|
|
let topRowStackSize = topRowStackMeasurement.measuredSize
|
|
|
|
let bottomRowStackMeasurement = bottomRowStack.configure(config: bottomRowStackConfig,
|
|
subviews: bottomRowStackSubviews,
|
|
subviewInfos: bottomRowStackSubviewInfos)
|
|
let bottomRowStackSize = bottomRowStackMeasurement.measuredSize
|
|
|
|
let vStackMeasurement = vStack.configure(config: vStackConfig,
|
|
subviews: vStackSubviews,
|
|
subviewInfos: [
|
|
topRowStackSize.asManualSubviewInfo,
|
|
bottomRowStackSize.asManualSubviewInfo
|
|
])
|
|
let vStackSize = vStackMeasurement.measuredSize
|
|
|
|
let outerHStackMeasurement = outerHStack.configure(config: outerHStackConfig,
|
|
subviews: [ avatarStack, vStack ],
|
|
subviewInfos: [
|
|
avatarStackSize.asManualSubviewInfo(hasFixedWidth: true),
|
|
vStackSize.asManualSubviewInfo
|
|
])
|
|
|
|
return HVCellMeasurement(avatarStackMeasurement: avatarStackMeasurement,
|
|
topRowStackMeasurement: topRowStackMeasurement,
|
|
bottomRowStackMeasurement: bottomRowStackMeasurement,
|
|
vStackMeasurement: vStackMeasurement,
|
|
outerHStackMeasurement: outerHStackMeasurement,
|
|
configs: configs)
|
|
}
|
|
}
|
|
|
|
private var topRowStackConfig: ManualStackView.Config {
|
|
ManualStackView.Config(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 6,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private var bottomRowStackConfig: ManualStackView.Config {
|
|
ManualStackView.Config(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 6,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private var vStackConfig: ManualStackView.Config {
|
|
ManualStackView.Config(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: 1,
|
|
layoutMargins: UIEdgeInsets(top: 7,
|
|
leading: 0,
|
|
bottom: 9,
|
|
trailing: 0))
|
|
}
|
|
|
|
private var outerHStackConfig: ManualStackView.Config {
|
|
ManualStackView.Config(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 12,
|
|
layoutMargins: UIEdgeInsets(hMargin: 16, vMargin: 0))
|
|
}
|
|
|
|
struct StatusIndicator {
|
|
let view: UIView
|
|
let size: CGSize
|
|
}
|
|
private func prepareStatusIndicatorView(thread: ThreadViewModel,
|
|
shouldHideStatusIndicator: Bool) -> StatusIndicator? {
|
|
guard !shouldHideStatusIndicator,
|
|
let outgoingMessage = thread.lastMessageForInbox as? TSOutgoingMessage else {
|
|
return nil
|
|
}
|
|
|
|
var statusIndicatorImage: UIImage?
|
|
var messageStatusViewTintColor = snippetColor
|
|
var shouldAnimateStatusIcon = false
|
|
|
|
let messageStatus =
|
|
MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage)
|
|
switch messageStatus {
|
|
case .uploading, .sending:
|
|
statusIndicatorImage = UIImage(named: "message_status_sending")
|
|
shouldAnimateStatusIcon = true
|
|
case .sent, .skipped:
|
|
if outgoingMessage.wasRemotelyDeleted {
|
|
return nil
|
|
}
|
|
statusIndicatorImage = UIImage(named: "message_status_sent")
|
|
case .delivered:
|
|
if outgoingMessage.wasRemotelyDeleted {
|
|
return nil
|
|
}
|
|
statusIndicatorImage = UIImage(named: "message_status_delivered")
|
|
case .read, .viewed:
|
|
if outgoingMessage.wasRemotelyDeleted {
|
|
return nil
|
|
}
|
|
statusIndicatorImage = UIImage(named: "message_status_read")
|
|
case .failed:
|
|
statusIndicatorImage = UIImage(named: "error-outline-12")
|
|
messageStatusViewTintColor = .ows_accentRed
|
|
case .pending:
|
|
statusIndicatorImage = UIImage(named: "error-outline-12")
|
|
messageStatusViewTintColor = .ows_gray60
|
|
}
|
|
if statusIndicatorImage == nil {
|
|
return nil
|
|
}
|
|
|
|
guard let image = statusIndicatorImage else {
|
|
return nil
|
|
}
|
|
messageStatusIconView.image = image.withRenderingMode(.alwaysTemplate)
|
|
messageStatusIconView.tintColor = messageStatusViewTintColor
|
|
|
|
if shouldAnimateStatusIcon {
|
|
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
|
animation.toValue = NSNumber(value: Double.pi * 2)
|
|
animation.duration = kSecondInterval * 1
|
|
animation.isCumulative = true
|
|
animation.repeatCount = .greatestFiniteMagnitude
|
|
messageStatusIconView.layer.add(animation, forKey: "animation")
|
|
} else {
|
|
messageStatusIconView.layer.removeAllAnimations()
|
|
}
|
|
|
|
return StatusIndicator(view: messageStatusIconView, size: image.size)
|
|
}
|
|
|
|
private var hasUnreadStyle: Bool {
|
|
guard let thread = thread else {
|
|
return false
|
|
}
|
|
return thread.hasUnreadMessages && overrideSnippet == nil
|
|
}
|
|
|
|
private func attributedSnippet(forThread thread: ThreadViewModel,
|
|
isBlocked: Bool) -> NSAttributedString {
|
|
|
|
let snippetText = NSMutableAttributedString()
|
|
if isBlocked {
|
|
// If thread is blocked, don't show a snippet or mute status.
|
|
snippetText.append(NSLocalizedString("HOME_VIEW_BLOCKED_CONVERSATION",
|
|
comment: "Table cell subtitle label for a conversation the user has blocked."),
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
} else if thread.hasPendingMessageRequest {
|
|
// If you haven't accepted the message request for this thread, don't show the latest message
|
|
|
|
// For group threads, show who we think added you (if we know)
|
|
if let addedToGroupByName = thread.homeViewInfo?.addedToGroupByName {
|
|
let addedToGroupFormat = NSLocalizedString("HOME_VIEW_MESSAGE_REQUEST_ADDED_TO_GROUP_FORMAT",
|
|
comment: "Table cell subtitle label for a group the user has been added to. {Embeds inviter name}")
|
|
snippetText.append(String(format: addedToGroupFormat, addedToGroupByName),
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
|
|
// Otherwise just show a generic "message request" message
|
|
} else {
|
|
snippetText.append(NSLocalizedString("HOME_VIEW_MESSAGE_REQUEST_CONVERSATION",
|
|
comment: "Table cell subtitle label for a conversation the user has not accepted."),
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
}
|
|
} else {
|
|
if let draftText = thread.homeViewInfo?.draftText?.nilIfEmpty,
|
|
!hasUnreadStyle {
|
|
snippetText.append(NSLocalizedString("HOME_VIEW_DRAFT_PREFIX",
|
|
comment: "A prefix indicating that a message preview is a draft"),
|
|
attributes: [
|
|
.font: snippetFont.ows_italic,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append(draftText,
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
} else if thread.homeViewInfo?.hasVoiceMemoDraft == true,
|
|
!hasUnreadStyle {
|
|
snippetText.append(NSLocalizedString("HOME_VIEW_DRAFT_PREFIX",
|
|
comment: "A prefix indicating that a message preview is a draft"),
|
|
attributes: [
|
|
.font: snippetFont.ows_italic,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append("🎤",
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append(" ",
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append(NSLocalizedString("ATTACHMENT_TYPE_VOICE_MESSAGE",
|
|
comment: "Short text label for a voice message attachment, used for thread preview and on the lock screen"),
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
} else {
|
|
if let lastMessageText = thread.homeViewInfo?.lastMessageText.filterStringForDisplay().nilIfEmpty {
|
|
if let senderName = thread.homeViewInfo?.lastMessageSenderName {
|
|
snippetText.append(senderName,
|
|
attributes: [
|
|
.font: snippetFont.ows_medium,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append(":",
|
|
attributes: [
|
|
.font: snippetFont.ows_medium,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
snippetText.append(" ",
|
|
attributes: [
|
|
.font: snippetFont
|
|
])
|
|
}
|
|
|
|
snippetText.append(lastMessageText,
|
|
attributes: [
|
|
.font: snippetFont,
|
|
.foregroundColor: snippetColor
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
return snippetText
|
|
}
|
|
|
|
private func shouldShowMuteIndicator(forThread thread: ThreadViewModel, isBlocked: Bool) -> Bool {
|
|
!hasOverrideSnippet && !isBlocked && !thread.hasPendingMessageRequest && thread.isMuted
|
|
}
|
|
|
|
private func dateTimeLabelConfig(configuration: Configuration) -> CVLabelConfig {
|
|
let thread = configuration.thread
|
|
var text: String = ""
|
|
if let labelDate = configuration.overrideDate ?? thread.homeViewInfo?.lastMessageDate {
|
|
text = DateUtil.formatDateShort(labelDate)
|
|
}
|
|
if hasUnreadStyle {
|
|
return CVLabelConfig(text: text,
|
|
font: dateTimeFont.ows_semibold,
|
|
textColor: Theme.primaryTextColor,
|
|
textAlignment: .trailing)
|
|
} else {
|
|
return CVLabelConfig(text: text,
|
|
font: dateTimeFont,
|
|
textColor: snippetColor,
|
|
textAlignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func nameLabelConfig(configuration: Configuration) -> CVLabelConfig {
|
|
let thread = configuration.thread
|
|
let text: String = {
|
|
if thread.threadRecord is TSContactThread {
|
|
if thread.threadRecord.isNoteToSelf {
|
|
return MessageStrings.noteToSelf
|
|
} else {
|
|
return thread.name
|
|
}
|
|
} else {
|
|
if let name: String = thread.name.nilIfEmpty {
|
|
return name
|
|
} else {
|
|
return MessageStrings.newGroupDefaultTitle
|
|
}
|
|
}
|
|
}()
|
|
return CVLabelConfig(text: text,
|
|
font: nameFont,
|
|
textColor: Theme.primaryTextColor,
|
|
lineBreakMode: .byTruncatingTail)
|
|
}
|
|
|
|
// MARK: - Reuse
|
|
|
|
@objc
|
|
public override func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
|
|
reset()
|
|
}
|
|
|
|
private func reset() {
|
|
for cvview in cvviews {
|
|
cvview.reset()
|
|
}
|
|
|
|
configuration = nil
|
|
avatarView.image = nil
|
|
avatarView.reset()
|
|
typingIndicatorView.reset()
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
// MARK: - Name
|
|
|
|
@objc
|
|
private func otherUsersProfileDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let address = notification.userInfo?[kNSNotificationKey_ProfileAddress] as? SignalServiceAddress,
|
|
address.isValid,
|
|
let contactThread = thread?.threadRecord as? TSContactThread,
|
|
contactThread.contactAddress == address else {
|
|
return
|
|
}
|
|
guard let configuration = configuration else {
|
|
return
|
|
}
|
|
reset()
|
|
configure(configuration)
|
|
}
|
|
|
|
@objc
|
|
private func typingIndicatorStateDidChange(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let thread = self.thread,
|
|
let notificationThreadId = notification.object as? String,
|
|
thread.threadRecord.uniqueId == notificationThreadId else {
|
|
return
|
|
}
|
|
|
|
updateTypingIndicatorState()
|
|
}
|
|
|
|
// MARK: - Typing Indicators
|
|
|
|
private var shouldShowTypingIndicators: Bool {
|
|
if !hasOverrideSnippet,
|
|
let thread = self.thread,
|
|
nil != typingIndicatorsImpl.typingAddress(forThread: thread.threadRecord) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func snippetLabelConfig(configuration: Configuration) -> CVLabelConfig {
|
|
let attributedText: NSAttributedString = {
|
|
if let overrideSnippet = self.overrideSnippet {
|
|
return overrideSnippet
|
|
}
|
|
return self.attributedSnippet(forThread: configuration.thread,
|
|
isBlocked: configuration.isBlocked)
|
|
}()
|
|
return CVLabelConfig(attributedText: attributedText,
|
|
font: snippetFont,
|
|
textColor: snippetColor,
|
|
numberOfLines: 2,
|
|
lineBreakMode: .byTruncatingTail)
|
|
}
|
|
|
|
private func updateTypingIndicatorState() {
|
|
AssertIsOnMainThread()
|
|
|
|
// We use "override snippets" to show "message" search results.
|
|
// We don't want to show typing indicators in that case.
|
|
if shouldShowTypingIndicators {
|
|
snippetLabel.isHidden = true
|
|
typingIndicatorView.isHidden = false
|
|
typingIndicatorView.startAnimation()
|
|
} else {
|
|
snippetLabel.isHidden = false
|
|
typingIndicatorView.isHidden = true
|
|
typingIndicatorView.stopAnimation()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public struct HVCellMeasurement {
|
|
let avatarStackMeasurement: ManualStackView.Measurement
|
|
let topRowStackMeasurement: ManualStackView.Measurement
|
|
let bottomRowStackMeasurement: ManualStackView.Measurement
|
|
let vStackMeasurement: ManualStackView.Measurement
|
|
let outerHStackMeasurement: ManualStackView.Measurement
|
|
fileprivate let configs: HomeViewCell.Configs
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
class NeverClearView: ManualLayoutViewWithLayer {
|
|
override var backgroundColor: UIColor? {
|
|
didSet {
|
|
if backgroundColor?.cgColor.alpha == 0 {
|
|
backgroundColor = oldValue
|
|
}
|
|
}
|
|
}
|
|
}
|