365 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|