Signal-iOS/SignalUI/RecipientPickers/ContactCellView.swift
Jordan Rose b0de59f2e2 Remove required from every init that is not dynamically dispatched
This included:
- Removing unavailable inits wholesale if no longer `required`
- Marking a few classes `final` so they could continue using
  `Self(...)` rather than `OWSWhatever(...)`
2024-04-01 15:27:20 -07:00

355 lines
14 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
public class ContactCellAccessoryView: NSObject {
let accessoryView: UIView
let size: CGSize
public init(accessoryView: UIView, size: CGSize) {
self.accessoryView = accessoryView
self.size = size
}
}
// MARK: -
public class ContactCellConfiguration: NSObject {
fileprivate enum CellDataSource {
case address(SignalServiceAddress)
case groupThread(TSGroupThread)
case `static`(name: String, avatar: UIImage)
}
fileprivate let dataSource: CellDataSource
public let localUserDisplayMode: LocalUserDisplayMode
public var forceDarkAppearance = false
public var accessoryMessage: String?
public var customName: String?
public var accessoryView: ContactCellAccessoryView?
public var attributedSubtitle: NSAttributedString?
public var shouldShowContactIcon = false
public var allowUserInteraction = false
public var badged = true // TODO: Badges Default false? Configure each use-case?
public var storyState: StoryContextViewState?
public var hasAccessoryText: Bool {
accessoryMessage?.nilIfEmpty != nil
}
public init(address: SignalServiceAddress, localUserDisplayMode: LocalUserDisplayMode) {
self.dataSource = .address(address)
self.localUserDisplayMode = localUserDisplayMode
super.init()
}
public init(groupThread: TSGroupThread, localUserDisplayMode: LocalUserDisplayMode) {
self.dataSource = .groupThread(groupThread)
self.localUserDisplayMode = localUserDisplayMode
super.init()
}
public init(name: String, avatar: UIImage) {
self.dataSource = .static(name: name, avatar: avatar)
self.localUserDisplayMode = .asUser
super.init()
}
public func useVerifiedSubtitle() {
let text = NSMutableAttributedString()
text.appendTemplatedImage(named: "check-extra-small", font: ContactCellView.subtitleFont)
text.append(" ", attributes: [:])
text.append(SafetyNumberStrings.verified, attributes: [:])
self.attributedSubtitle = text
}
}
// MARK: -
public class ContactCellView: ManualStackView {
private var configuration: ContactCellConfiguration? {
didSet {
ensureObservers()
}
}
public static var avatarSizeClass: ConversationAvatarView.Configuration.SizeClass { .thirtySix }
private var avatarDataSource: ConversationAvatarDataSource? {
switch configuration?.dataSource {
case .groupThread(let thread): return .thread(thread)
case .address(let address): return .address(address)
case .static(_, let avatar): return .asset(avatar: avatar, badge: nil)
case nil: return nil
}
}
// TODO: Update localUserDisplayMode.
private let avatarView = ConversationAvatarView(
sizeClass: avatarSizeClass,
localUserDisplayMode: .asUser,
useAutolayout: false)
public var tooltipTailReferenceView: UIView { return avatarView }
public static let avatarTextHSpacing: CGFloat = 12
private lazy var groupStoryBadgeView: UIView = {
let backgroundView = UIView.container()
let symbolView = UIImageView(image: UIImage(named: "stories-fill-compact"))
backgroundView.addSubview(symbolView)
symbolView.tintColor = .ows_white
symbolView.autoSetDimensions(to: .square(12))
symbolView.autoCenterInSuperview()
return backgroundView
}()
private let nameLabel = CVLabel()
private let subtitleLabel = CVLabel()
private let accessoryLabel = CVLabel()
private let textStack = ManualStackView(name: "textStack")
public init() {
super.init(name: "ContactCellView")
nameLabel.lineBreakMode = .byTruncatingTail
accessoryLabel.textAlignment = .right
self.shouldDeactivateConstraints = false
}
private var nameLabelFont: UIFont { OWSTableItem.primaryLabelFont }
fileprivate static var subtitleFont: UIFont { .dynamicTypeCaption1Clamped }
private func nameLabelColor(forceDarkAppearance: Bool) -> UIColor {
forceDarkAppearance ? Theme.darkThemePrimaryColor : Theme.primaryTextColor
}
private func configureFontsAndColors(forceDarkAppearance: Bool) {
nameLabel.font = nameLabelFont
subtitleLabel.font = Self.subtitleFont
accessoryLabel.font = .dynamicTypeSubheadlineClamped
nameLabel.textColor = nameLabelColor(forceDarkAppearance: forceDarkAppearance)
subtitleLabel.textColor = (forceDarkAppearance ? Theme.darkThemeSecondaryTextAndIconColor : Theme.secondaryTextAndIconColor)
accessoryLabel.textColor = Theme.isDarkThemeEnabled ? .ows_gray25 : .ows_gray45
if let nameLabelText = nameLabel.attributedText?.string.nilIfEmpty,
let nameLabelColor = nameLabel.textColor {
nameLabel.attributedText = nameLabelText.asAttributedString(attributes: [
.foregroundColor: nameLabelColor
])
}
}
public func configure(configuration: ContactCellConfiguration,
transaction: SDSAnyReadTransaction) {
AssertIsOnMainThread()
owsAssertDebug(!shouldDeactivateConstraints)
self.configuration = configuration
self.isUserInteractionEnabled = configuration.allowUserInteraction
avatarView.update(transaction) { config in
config.dataSource = avatarDataSource
config.addBadgeIfApplicable = configuration.badged
config.localUserDisplayMode = configuration.localUserDisplayMode
if let storyState = configuration.storyState {
config.storyConfiguration = .fixed(storyState)
} else {
config.storyConfiguration = .disabled
}
}
if avatarDataSource?.isGroupAvatar ?? false,
let storyState = configuration.storyState {
// Group story. Add badge
avatarView.addSubview(groupStoryBadgeView)
let badgeColor: UIColor
switch storyState {
case .unviewed:
badgeColor = .ows_accentBlue
case .viewed, .noStories:
badgeColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray25
}
let size: CGFloat = 20
groupStoryBadgeView.backgroundColor = badgeColor
groupStoryBadgeView.layer.cornerRadius = size/2
groupStoryBadgeView.layer.masksToBounds = true
groupStoryBadgeView.autoSetDimensions(to: .square(size))
groupStoryBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -2)
groupStoryBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: -5)
} else {
// Not a group or not a story. Remove badge
groupStoryBadgeView.removeFromSuperview()
}
// Update fonts to reflect changes to dynamic type.
configureFontsAndColors(forceDarkAppearance: configuration.forceDarkAppearance)
updateNameLabels(configuration: configuration, transaction: transaction)
// Configure self.
do {
var rootStackSubviews: [UIView] = [ avatarView ]
let avatarSize = Self.avatarSizeClass.size
var rootStackSubviewInfos = [ avatarSize.asManualSubviewInfo(hasFixedSize: true) ]
// Configure textStack.
do {
var textStackSubviews = [ nameLabel ]
let nameSize = nameLabel.sizeThatFits(.square(.greatestFiniteMagnitude))
var textStackSubviewInfos = [ nameSize.asManualSubviewInfo ]
if let attributedSubtitle = configuration.attributedSubtitle?.nilIfEmpty {
subtitleLabel.attributedText = attributedSubtitle
textStackSubviews.append(subtitleLabel)
let subtitleSize = subtitleLabel.sizeThatFits(.square(.greatestFiniteMagnitude))
textStackSubviewInfos.append(subtitleSize.asManualSubviewInfo)
}
let textStackConfig = ManualStackView.Config(axis: .vertical,
alignment: .leading,
spacing: 0,
layoutMargins: .zero)
let textStackMeasurement = textStack.configure(config: textStackConfig,
subviews: textStackSubviews,
subviewInfos: textStackSubviewInfos)
rootStackSubviews.append(textStack)
rootStackSubviewInfos.append(textStackMeasurement.measuredSize.asManualSubviewInfo)
}
if let accessoryMessage = configuration.accessoryMessage {
accessoryLabel.text = accessoryMessage
let labelSize = accessoryLabel.sizeThatFits(.square(.greatestFiniteMagnitude))
configuration.accessoryView = ContactCellAccessoryView(accessoryView: accessoryLabel,
size: labelSize)
}
if let accessoryView = configuration.accessoryView {
rootStackSubviews.append(accessoryView.accessoryView)
rootStackSubviewInfos.append(accessoryView.size.asManualSubviewInfo(hasFixedSize: true))
}
let rootStackConfig = ManualStackView.Config(axis: .horizontal,
alignment: .center,
spacing: Self.avatarTextHSpacing,
layoutMargins: .zero)
let rootStackMeasurement = ManualStackView.measure(config: rootStackConfig,
subviewInfos: rootStackSubviewInfos)
self.configure(config: rootStackConfig,
measurement: rootStackMeasurement,
subviews: rootStackSubviews)
}
}
// MARK: - Notifications
private func ensureObservers() {
NotificationCenter.default.removeObserver(self)
if case .address = configuration?.dataSource {
NotificationCenter.default.addObserver(self,
selector: #selector(otherUsersProfileChanged(notification:)),
name: UserProfileNotifications.otherUsersProfileDidChange,
object: nil)
}
}
// MARK: -
private func updateNameLabelsWithSneakyTransaction(configuration: ContactCellConfiguration) {
databaseStorage.read { transaction in
updateNameLabels(configuration: configuration, transaction: transaction)
}
}
private func updateNameLabels(configuration: ContactCellConfiguration,
transaction: SDSAnyReadTransaction) {
AssertIsOnMainThread()
let textColor = self.nameLabelColor(forceDarkAppearance: configuration.forceDarkAppearance)
let nameString = { () -> NSAttributedString in
if let customName = configuration.customName?.nilIfEmpty {
return customName.asAttributedString
}
switch configuration.dataSource {
case .address(let address):
return contactsManager.nameForAddress(address,
localUserDisplayMode: configuration.localUserDisplayMode,
short: false,
transaction: transaction)
case .groupThread(let thread):
let threadName = contactsManager.displayName(for: thread, transaction: transaction)
return threadName.asAttributedString(attributes: [
.foregroundColor: textColor,
])
case .static(let name, _):
return name.asAttributedString
}
}()
if configuration.shouldShowContactIcon {
let contactIcon = SignalSymbol.personCircle.attributedString(
dynamicTypeBaseSize: 14,
weight: .bold,
withLeadingSpace: true,
attributes: [.foregroundColor: textColor]
)
nameLabel.attributedText = nameString.stringByAppendingString(contactIcon)
} else {
nameLabel.attributedText = nameString
}
}
public override func reset() {
super.reset()
NotificationCenter.default.removeObserver(self)
configuration = nil
avatarView.reset()
textStack.reset()
nameLabel.text = nil
subtitleLabel.text = nil
accessoryLabel.text = nil
}
@objc
private func otherUsersProfileChanged(notification: Notification) {
AssertIsOnMainThread()
guard let configuration = self.configuration else {
return
}
guard let changedAddress = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress,
changedAddress.isValid else {
owsFailDebug("changedAddress was unexpectedly nil")
return
}
if case .address(changedAddress) = configuration.dataSource {
updateNameLabelsWithSneakyTransaction(configuration: configuration)
}
}
}