Signal-iOS/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentThreadDetails.swift
2021-01-05 11:03:42 -03:00

365 lines
16 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
public var cellReuseIdentifier: CVCellReuseIdentifier {
CVCellReuseIdentifier.threadDetails
}
public let isDedicatedCell = false
private let threadDetails: CVComponentState.ThreadDetails
private var avatarImage: UIImage? { threadDetails.avatar }
private var titleText: String { threadDetails.titleText }
private var detailsText: String? { threadDetails.detailsText }
private var mutualGroupsText: NSAttributedString? { threadDetails.mutualGroupsText }
required init(itemModel: CVItemModel, threadDetails: CVComponentState.ThreadDetails) {
self.threadDetails = threadDetails
super.init(itemModel: itemModel)
}
public func configure(cellView: UIView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
cellSelection: CVCellSelection,
swipeToReplyState: CVSwipeToReplyState,
componentView: CVComponentView) {
owsAssertDebug(cellView.layoutMargins == .zero)
owsAssertDebug(cellView.subviews.isEmpty)
configureForRendering(componentView: componentView,
cellMeasurement: cellMeasurement,
componentDelegate: componentDelegate)
let rootView = componentView.rootView
cellView.addSubview(rootView)
rootView.autoPinEdgesToSuperviewMargins()
}
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewThreadDetails()
}
public func configureForRendering(componentView componentViewParam: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate) {
guard let componentView = componentViewParam as? CVComponentViewThreadDetails else {
owsFailDebug("Unexpected componentView.")
componentViewParam.reset()
return
}
let stackView = componentView.stackView
stackView.apply(config: stackViewConfig)
let avatarView = AvatarImageView(image: self.avatarImage)
componentView.avatarView = avatarView
avatarView.autoSetDimensions(to: CGSize(square: avatarDiameter))
avatarView.setContentHuggingHigh()
avatarView.setCompressionResistanceHigh()
stackView.addArrangedSubview(avatarView)
stackView.addArrangedSubview(UIView.spacer(withHeight: 1))
let titleLabel = componentView.titleLabel
titleLabelConfig.applyForRendering(label: titleLabel)
stackView.addArrangedSubview(titleLabel)
if let detailsText = self.detailsText {
let detailsLabel = componentView.detailsLabel
detailsLabelConfig(text: detailsText).applyForRendering(label: detailsLabel)
stackView.addArrangedSubview(detailsLabel)
}
if let mutualGroupsText = self.mutualGroupsText {
let mutualGroupsLabel = componentView.mutualGroupsLabel
mutualGroupsLabelConfig(attributedText: mutualGroupsText).applyForRendering(label: mutualGroupsLabel)
stackView.addArrangedSubview(UIView.spacer(withHeight: 5))
stackView.addArrangedSubview(mutualGroupsLabel)
}
}
private var titleLabelConfig: CVLabelConfig {
CVLabelConfig(text: titleText,
font: UIFont.ows_dynamicTypeTitle1.ows_semibold,
textColor: Theme.secondaryTextAndIconColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center)
}
private func detailsLabelConfig(text: String) -> CVLabelConfig {
CVLabelConfig(text: text,
font: .ows_dynamicTypeSubheadline,
textColor: Theme.secondaryTextAndIconColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center)
}
private func mutualGroupsLabelConfig(attributedText: NSAttributedString) -> CVLabelConfig {
CVLabelConfig(attributedText: attributedText,
font: .ows_dynamicTypeSubheadline,
textColor: Theme.secondaryTextAndIconColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center)
}
private static let avatarDiameter: UInt = 112
private var avatarDiameter: CGFloat { CGFloat(Self.avatarDiameter) }
static func buildComponentState(thread: TSThread,
transaction: SDSAnyReadTransaction,
avatarBuilder: CVAvatarBuilder) -> CVComponentState.ThreadDetails {
if let contactThread = thread as? TSContactThread {
return buildComponentState(contactThread: contactThread,
transaction: transaction,
avatarBuilder: avatarBuilder)
} else if let groupThread = thread as? TSGroupThread {
return buildComponentState(groupThread: groupThread,
transaction: transaction,
avatarBuilder: avatarBuilder)
} else {
owsFailDebug("Invalid thread.")
return CVComponentState.ThreadDetails(avatar: nil,
titleText: TSGroupThread.defaultGroupName,
detailsText: nil,
mutualGroupsText: nil)
}
}
private static func buildComponentState(contactThread: TSContactThread,
transaction: SDSAnyReadTransaction,
avatarBuilder: CVAvatarBuilder) -> CVComponentState.ThreadDetails {
let avatar = avatarBuilder.buildAvatar(forAddress: contactThread.contactAddress, diameter: avatarDiameter)
let contactName = Self.contactsManager.displayName(for: contactThread.contactAddress,
transaction: transaction)
let titleText = { () -> String in
if contactThread.isNoteToSelf {
return MessageStrings.noteToSelf
} else {
return contactName
}
}()
let detailsText = { () -> String? in
if contactThread.isNoteToSelf {
return NSLocalizedString("THREAD_DETAILS_NOTE_TO_SELF_EXPLANATION",
comment: "Subtitle appearing at the top of the users 'note to self' conversation")
}
var details: String?
let threadName = contactName
if let phoneNumber = contactThread.contactAddress.phoneNumber, phoneNumber != threadName {
let formattedNumber = PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber)
if threadName != formattedNumber {
details = formattedNumber
}
}
if let username = Self.profileManager.username(for: contactThread.contactAddress,
transaction: transaction) {
if let formattedUsername = CommonFormats.formatUsername(username), threadName != formattedUsername {
if let existingDetails = details {
details = existingDetails + "\n" + formattedUsername
} else {
details = formattedUsername
}
}
}
return details
}()
let mutualGroupsText = { () -> NSAttributedString? in
guard !contactThread.contactAddress.isLocalAddress else {
// Don't show mutual groups for "Note to Self".
return nil
}
let groupThreads = TSGroupThread.groupThreads(with: contactThread.contactAddress, transaction: transaction)
let mutualGroupNames = groupThreads.map { $0.groupNameOrDefault }
let formatString: String
var groupsToInsert = mutualGroupNames
switch mutualGroupNames.count {
case 0:
return nil
case 1:
formatString = NSLocalizedString(
"THREAD_DETAILS_ONE_MUTUAL_GROUP",
comment: "A string indicating a mutual group the user shares with this contact. Embeds {{mutual group name}}"
)
case 2:
formatString = NSLocalizedString(
"THREAD_DETAILS_TWO_MUTUAL_GROUP",
comment: "A string indicating two mutual groups the user shares with this contact. Embeds {{mutual group name}}"
)
case 3:
formatString = NSLocalizedString(
"THREAD_DETAILS_THREE_MUTUAL_GROUP",
comment: "A string indicating three mutual groups the user shares with this contact. Embeds {{mutual group name}}"
)
default:
formatString = NSLocalizedString(
"THREAD_DETAILS_MORE_MUTUAL_GROUP",
comment: "A string indicating two mutual groups the user shares with this contact and that there are more unlisted. Embeds {{mutual group name}}"
)
groupsToInsert = Array(groupsToInsert[0...1])
}
var formatStringCount = formatString.components(separatedBy: "%@").count
if formatString.count > 1 { formatStringCount -= 1 }
guard formatStringCount == groupsToInsert.count else {
owsFailDebug("Incorrect number of format characters in string")
return nil
}
let mutableAttributedString = NSMutableAttributedString(string: formatString)
// We don't use `String(format:)` so that we can make sure each group name is bold.
for groupName in groupsToInsert {
let nextInsertionPoint = (mutableAttributedString.string as NSString).range(of: "%@")
guard nextInsertionPoint.location != NSNotFound else {
owsFailDebug("Unexpectedly tried to insert too many group names")
return nil
}
let boldGroupName = NSAttributedString(string: groupName, attributes: [.font: UIFont.ows_dynamicTypeSubheadline.ows_semibold])
mutableAttributedString.replaceCharacters(in: nextInsertionPoint, with: boldGroupName)
}
// We also need to insert the count if we're more than 3
if mutualGroupNames.count > 3 {
let nextInsertionPoint = (mutableAttributedString.string as NSString).range(of: "%lu")
guard nextInsertionPoint.location != NSNotFound else {
owsFailDebug("Unexpectedly failed to insert more count")
return nil
}
mutableAttributedString.replaceCharacters(in: nextInsertionPoint, with: "\(mutualGroupNames.count - 2)")
} else if mutableAttributedString.string.range(of: "%lu") != nil {
owsFailDebug("unexpected format string remaining in string")
return nil
}
return mutableAttributedString
}()
return CVComponentState.ThreadDetails(avatar: avatar,
titleText: titleText,
detailsText: detailsText,
mutualGroupsText: mutualGroupsText)
}
private static func buildComponentState(groupThread: TSGroupThread,
transaction: SDSAnyReadTransaction,
avatarBuilder: CVAvatarBuilder) -> CVComponentState.ThreadDetails {
// If we need to reload this cell to reflect changes to any of the
// state captured here, we need update the didThreadDetailsChange().
let avatar = avatarBuilder.buildAvatar(forGroupThread: groupThread, diameter: avatarDiameter)
let titleText = groupThread.groupNameOrDefault
let detailsText = { () -> String? in
if let groupModelV2 = groupThread.groupModel as? TSGroupModelV2,
groupModelV2.isPlaceholderModel {
// Don't show details for a placeholder.
return nil
}
let memberCount = groupThread.groupModel.groupMembership.fullMembers.count
return GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount)
}()
return CVComponentState.ThreadDetails(avatar: avatar,
titleText: titleText,
detailsText: detailsText,
mutualGroupsText: nil)
}
private var stackViewConfig: CVStackViewConfig {
CVStackViewConfig(axis: .vertical,
alignment: .center,
spacing: 3,
layoutMargins: UIEdgeInsets(top: 8, leading: 16, bottom: 28, trailing: 16))
}
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
let maxContentWidth = maxWidth - (stackViewConfig.layoutMargins.left + stackViewConfig.layoutMargins.right)
var subviewSizes = [CGSize]()
subviewSizes.append(CGSize(square: avatarDiameter))
subviewSizes.append(CGSize(square: 1))
let titleSize = CVText.measureLabel(config: titleLabelConfig, maxWidth: maxContentWidth)
subviewSizes.append(titleSize)
if let detailsText = self.detailsText {
let detailsSize = CVText.measureLabel(config: detailsLabelConfig(text: detailsText),
maxWidth: maxContentWidth)
subviewSizes.append(detailsSize)
}
if let mutualGroupsText = self.mutualGroupsText {
let mutualGroupsSize = CVText.measureLabel(config: mutualGroupsLabelConfig(attributedText: mutualGroupsText),
maxWidth: maxContentWidth)
subviewSizes.append(CGSize(square: 5))
subviewSizes.append(mutualGroupsSize)
}
return CVStackView.measure(config: stackViewConfig, subviewSizes: subviewSizes).ceil
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
@objc
public class CVComponentViewThreadDetails: NSObject, CVComponentView {
fileprivate var avatarView: AvatarImageView?
fileprivate let titleLabel = UILabel()
fileprivate let detailsLabel = UILabel()
fileprivate let mutualGroupsLabel = UILabel()
fileprivate let stackView = OWSStackView(name: "threadDetails")
public var isDedicatedCellView = false
public var rootView: UIView {
stackView
}
// MARK: -
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
stackView.reset()
titleLabel.text = nil
detailsLabel.text = nil
mutualGroupsLabel.text = nil
avatarView = nil
}
}
}