Signal-iOS/Signal/ConversationView/Components/CVComponentThreadDetails.swift
2026-03-26 17:10:38 -05:00

1236 lines
50 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Lottie
import SignalServiceKit
public import SignalUI
public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
public var componentKey: CVComponentKey { .threadDetails }
public var cellReuseIdentifier: CVCellReuseIdentifier {
CVCellReuseIdentifier.threadDetails
}
public let isDedicatedCell = false
private let threadDetails: CVComponentState.ThreadDetails
private var avatarDataSource: ConversationAvatarDataSource? { threadDetails.avatarDataSource }
private var titleText: String { threadDetails.titleText }
private var bioText: String? { threadDetails.bioText }
private var groupDescriptionText: String? { threadDetails.groupDescriptionText }
private var canTapTitle: Bool {
thread is TSContactThread && !thread.isNoteToSelf
}
init(itemModel: CVItemModel, threadDetails: CVComponentState.ThreadDetails) {
self.threadDetails = threadDetails
super.init(itemModel: itemModel)
}
public func configureCellRootComponent(
cellView: UIView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState,
componentView: CVComponentView,
) {
Self.configureCellRootComponent(
rootComponent: self,
cellView: cellView,
cellMeasurement: cellMeasurement,
componentDelegate: componentDelegate,
componentView: componentView,
)
}
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewThreadDetails()
}
override public func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
guard let componentView = componentView as? CVComponentViewThreadDetails else {
owsFailDebug("Unexpected componentView.")
return nil
}
return componentView.wallpaperBlurView
}
public func configureForRendering(
componentView componentViewParam: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
) {
guard let componentView = componentViewParam as? CVComponentViewThreadDetails else {
owsFailDebug("Unexpected componentView.")
componentViewParam.reset()
return
}
let outerStackView = componentView.outerStackView
let innerStackView = componentView.innerStackView
innerStackView.reset()
outerStackView.reset()
outerStackView.insetsLayoutMarginsFromSafeArea = false
innerStackView.insetsLayoutMarginsFromSafeArea = false
var innerViews = [UIView]()
let avatarView = ConversationAvatarView(sizeClass: avatarSizeClass, localUserDisplayMode: .asUser, useAutolayout: false)
avatarView.updateWithSneakyTransactionIfNecessary { configuration in
configuration.dataSource = avatarDataSource
}
componentView.avatarView = avatarView
if threadDetails.isAvatarBlurred {
let avatarWrapper = ManualLayoutView(name: "avatarWrapper")
avatarWrapper.addSubviewToFillSuperviewEdges(avatarView)
innerViews.append(avatarWrapper)
var unblurAvatarSubviewInfos = [ManualStackSubviewInfo]()
let subviews: [UIView]
if threadDetails.isAvatarBeingDownloaded {
let lottieView = LottieAnimationView(name: "indeterminate_spinner_44")
lottieView.loopMode = .loop
lottieView.play()
unblurAvatarSubviewInfos.append(CGSize.square(44).asManualSubviewInfo(hasFixedSize: true))
subviews = [lottieView]
} else {
let unblurAvatarIconView = CVImageView()
unblurAvatarIconView.setTemplateImageName("tap-outline-24", tintColor: .ows_white)
unblurAvatarSubviewInfos.append(CGSize.square(24).asManualSubviewInfo(hasFixedSize: true))
let unblurAvatarLabelConfig = CVLabelConfig.unstyledText(
OWSLocalizedString(
"THREAD_DETAILS_TAP_TO_UNBLUR_AVATAR",
comment: "Indicator that a blurred avatar can be revealed by tapping.",
),
font: UIFont.dynamicTypeSubheadlineClamped,
textColor: .ows_white,
)
let maxWidth = CGFloat(avatarSizeClass.diameter) - 12
let unblurAvatarLabelSize = CVText.measureLabel(
config: unblurAvatarLabelConfig,
maxWidth: maxWidth,
)
unblurAvatarSubviewInfos.append(unblurAvatarLabelSize.asManualSubviewInfo)
let unblurAvatarLabel = CVLabel()
unblurAvatarLabelConfig.applyForRendering(label: unblurAvatarLabel)
subviews = [unblurAvatarIconView, unblurAvatarLabel]
}
let unblurAvatarStackConfig = ManualStackView.Config(
axis: .vertical,
alignment: .center,
spacing: 8,
layoutMargins: .zero,
)
let unblurAvatarStackMeasurement = ManualStackView.measure(
config: unblurAvatarStackConfig,
subviewInfos: unblurAvatarSubviewInfos,
)
let unblurAvatarStack = ManualStackView(name: "unblurAvatarStack")
unblurAvatarStack.configure(
config: unblurAvatarStackConfig,
measurement: unblurAvatarStackMeasurement,
subviews: subviews,
)
avatarWrapper.addSubviewToCenterOnSuperview(
unblurAvatarStack,
size: unblurAvatarStackMeasurement.measuredSize,
)
} else {
innerViews.append(avatarView)
}
innerViews.append(UIView.spacer(withHeight: vSpacingTitle))
if conversationStyle.hasWallpaper {
let wallpaperBlurView = componentView.ensureWallpaperBlurView()
configureWallpaperBlurView(
wallpaperBlurView: wallpaperBlurView,
componentDelegate: componentDelegate,
bubbleConfig: BubbleConfiguration(
corners: .uniform(24),
stroke: ConversationStyle.bubbleStroke(isDarkThemeEnabled: isDarkThemeEnabled),
),
)
innerStackView.addSubviewToFillSuperviewEdges(wallpaperBlurView)
}
let titleButton = componentView.titleButton
titleLabelConfig.applyForRendering(button: titleButton)
self.configureTitleAction(button: titleButton, delegate: componentDelegate)
innerViews.append(titleButton)
if let bioText = self.bioText {
let bioLabel = componentView.bioLabel
bioLabelConfig(text: bioText).applyForRendering(label: bioLabel)
innerViews.append(UIView.spacer(withHeight: vSpacingSubtitle))
innerViews.append(bioLabel)
}
if let groupDescriptionText = self.groupDescriptionText {
let groupDescriptionPreviewView = componentView.groupDescriptionPreviewView
let config = groupDescriptionTextLabelConfig(text: groupDescriptionText)
groupDescriptionPreviewView.apply(config: config)
groupDescriptionPreviewView.groupName = titleText
innerViews.append(groupDescriptionPreviewView)
}
let namesEducationLabel = componentView.profileNamesEducationLabel
let detailsButton = componentView.detailsButton
let mutualGroupsLabel = componentView.mutualGroupsLabel
let showTipsButton = componentView.showTipsButton
let groupInfoWrapper = ManualLayoutViewWithLayer(name: "groupWrapper")
var groupInfoSubviewInfos = [ManualStackSubviewInfo]()
var groupInfoSubviews: [UIView] = []
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowLowTrustWarning {
let reviewCarefullyLabel = componentView.reviewCarefullyLabel
groupInfoSubviews.append(reviewCarefullyLabel)
let config = self.reviewCarefullyConfig()
config.applyForRendering(label: reviewCarefullyLabel)
groupInfoSubviewInfos.append(reviewCarefullyLabel.sizeThatFitsMaxSize.asManualSubviewInfo)
}
innerViews.append(UIView.spacer(withHeight: vSpacingSafetySection(hasWallpaper: conversationStyle.hasWallpaper)))
if conversationStyle.hasWallpaper {
// Add divider before mutual groups
let divider = UIView()
divider.autoSetDimension(.width, toSize: cellMeasurement.cellSize.width)
divider.autoSetDimension(.height, toSize: 1)
divider.backgroundColor = UIColor(
white: Theme.isDarkThemeEnabled ? 1 : 0,
alpha: 0.12,
)
innerViews.append(divider)
} else {
groupInfoWrapper.layer.cornerRadius = 18
groupInfoWrapper.layer.borderWidth = 2
if Theme.isDarkThemeEnabled {
groupInfoWrapper.layer.borderColor = nil
groupInfoWrapper.backgroundColor = UIColor(white: 1, alpha: 0.08)
} else {
groupInfoWrapper.layer.borderColor = UIColor(white: 0, alpha: 0.06).cgColor
groupInfoWrapper.backgroundColor = Theme.backgroundColor
groupInfoWrapper.setShadow(radius: 4, opacity: 0.04, offset: .init(width: 0, height: 2))
groupInfoWrapper.setShadow(radius: 4, opacity: 0.04, offset: .init(width: 0, height: 2))
}
}
innerViews.append(groupInfoWrapper)
let maxWidth = cellMeasurement.cellSize.width
- outerStackConfig.layoutMargins.totalWidth
- innerStackConfig.layoutMargins.totalWidth
- (hPaddingSafetySection * 2)
if safetySection.shouldShowProfileNamesEducation {
groupInfoSubviews.append(namesEducationLabel)
let config = namesEducationConfig(type: safetySection.threadType)
config.applyForRendering(button: namesEducationLabel)
namesEducationLabel.block = { [weak componentDelegate] in
componentDelegate?.didTapNameEducation(type: safetySection.threadType)
}
let size = CVText.measureLabel(config: config, maxWidth: maxWidth)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
if let detailsText = safetySection.detailsText {
groupInfoSubviews.append(detailsButton)
let config = mutualGroupsLabelConfig(attributedText: detailsText)
config.applyForRendering(button: detailsButton)
// Tap to see member count
if safetySection.threadType == .group {
detailsButton.block = { [weak componentDelegate] in
componentDelegate?.didTapShowConversationSettings()
}
}
let size = CVText.measureLabel(config: config, maxWidth: maxWidth)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
if let mutualGroupsText = safetySection.mutualGroupsText {
let mutualGroupsLabelConfig = mutualGroupsLabelConfig(attributedText: mutualGroupsText)
mutualGroupsLabelConfig.applyForRendering(label: mutualGroupsLabel)
let mutualGroupsLabelSize = CVText.measureLabel(config: mutualGroupsLabelConfig, maxWidth: maxWidth)
groupInfoSubviewInfos.append(mutualGroupsLabelSize.asManualSubviewInfo)
groupInfoSubviews.append(mutualGroupsLabel)
}
if safetySection.shouldShowSafetyTipsButton {
groupInfoSubviews.append(showTipsButton)
let safetyButtonLabelConfig = safetyTipsConfig()
safetyButtonLabelConfig.applyForRendering(button: showTipsButton)
showTipsButton.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray60 : .ows_gray05
showTipsButton.ows_contentEdgeInsets = .init(hMargin: 12.0, vMargin: 8.0)
showTipsButton.dimsWhenHighlighted = true
showTipsButton.block = { [weak self] in
self?.didShowTips(type: safetySection.threadType)
}
groupInfoSubviewInfos.append(showTipsButton.sizeThatFitsMaxSize.asManualSubviewInfo)
}
let groupInfoStackMeasurement = ManualStackView.measure(
config: groupStackConfig,
subviewInfos: groupInfoSubviewInfos,
)
let groupInfoStack = ManualStackView(name: "groupInfoStack")
groupInfoStack.configure(
config: groupStackConfig,
measurement: groupInfoStackMeasurement,
subviews: groupInfoSubviews,
)
groupInfoWrapper.addSubviewToCenterOnSuperview(
groupInfoStack,
size: groupInfoStackMeasurement.measuredSize,
)
} else {
innerViews.append(UIView.spacer(withHeight: minBottomPadding))
}
innerStackView.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: innerViews,
)
let outerViews = [innerStackView]
outerStackView.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: outerViews,
)
}
private var titleLabelConfig: CVLabelConfig {
let font = UIFont.dynamicTypeTitle1.semibold()
let textColor = Theme.primaryTextColor
let attributedString = NSMutableAttributedString(string: titleText, attributes: [
.font: font,
.foregroundColor: textColor,
])
if threadDetails.shouldShowVerifiedBadge {
attributedString.append(" ")
let verifiedBadgeImage = Theme.iconImage(.official)
let verifiedBadgeAttachment = NSAttributedString.with(
image: verifiedBadgeImage,
font: .dynamicTypeTitle3,
centerVerticallyRelativeTo: font,
heightReference: .pointSize,
)
attributedString.append(verifiedBadgeAttachment)
}
if canTapTitle {
attributedString.append(
SignalSymbol.chevronTrailing(for: titleText).attributedString(
dynamicTypeBaseSize: 24,
leadingCharacter: .nonBreakingSpace,
attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
),
)
}
return CVLabelConfig(
text: .attributedText(attributedString),
displayConfig: .forUnstyledText(font: font, textColor: textColor),
font: font,
textColor: textColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center,
)
}
private func configureTitleAction(
button: OWSButton,
delegate: CVComponentDelegate?,
) {
guard
canTapTitle,
let contactThread = thread as? TSContactThread
else {
button.isEnabled = false
button.dimsWhenHighlighted = false
button.block = {}
return
}
button.dimsWhenHighlighted = true
button.block = { [weak delegate] in
delegate?.didTapContactName(thread: contactThread)
}
button.isEnabled = true
}
private func bioLabelConfig(text: String) -> CVLabelConfig {
CVLabelConfig.unstyledText(
text,
font: .dynamicTypeSubheadline,
textColor: Theme.primaryTextColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center,
)
}
private static var mutualGroupsFont: UIFont { .dynamicTypeSubheadline }
private static var mutualGroupsTextColor: UIColor { Theme.primaryTextColor }
private static var underlineColor: UIColor { UIColor.Signal.transparentSeparator }
private static var reviewCarefullyFont: UIFont { .dynamicTypeSubheadline.semibold() }
private static var reviewCarefullyTextColor: UIColor { UIColor(rgbHex: 0xA88746) }
private func reviewCarefullyConfig() -> CVLabelConfig {
CVLabelConfig(
text: .attributedText(
.composed(of: [
NSAttributedString.with(
image: UIImage(named: "error-triangle-fill-compact")!,
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: Self.reviewCarefullyFont,
heightReference: .pointSize,
),
SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
OWSLocalizedString(
"SYSTEM_MESSAGE_UNKNOWN_THREAD_REVIEW_CAREFULLY_WARNING",
comment: "Indicator warning about an unknown contact thread",
),
]).styled(with: .alignment(.center)),
),
displayConfig: .forUnstyledText(
font: Self.reviewCarefullyFont,
textColor: Self.reviewCarefullyTextColor,
),
font: Self.reviewCarefullyFont,
textColor: Self.reviewCarefullyTextColor,
numberOfLines: 0,
)
}
private func namesEducationIcon(type: SafetyTipsType) -> UIImage {
switch type {
case .contact:
return UIImage(named: "person-questionmark-compact")!
case .group:
return UIImage(named: "group-questionmark-compact")!
}
}
private func underlinedNamesEducationString(type: SafetyTipsType) -> NSAttributedString {
let (subject, predicate): (String, String) = switch type {
case .contact:
(
OWSLocalizedString(
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT",
comment: "Label displayed below profiles. This is the subject part of the sentence 'Profile names are not verified'. It is embedded into THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE.",
),
OWSLocalizedString(
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE",
comment: "Label displayed below profiles. This is the predicate part of the sentence 'Profile names are not verified'. Embeds {{ THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT }}",
),
)
case .group:
(
OWSLocalizedString(
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT",
comment: "Label displayed below group info. This is the subject part of the sentence 'Group names are not verified'. It is embedded into THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE.",
),
OWSLocalizedString(
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE",
comment: "Label displayed below group info. This is the predicate part of the sentence 'Group names are not verified'. Embeds {{ THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT }}",
),
)
}
let formattedString = String(format: predicate, subject)
let subjectRange = NSString(string: formattedString).range(of: subject)
let attributedString = NSMutableAttributedString(string: formattedString)
attributedString.addAttributes(
[
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: Self.underlineColor,
],
range: subjectRange,
)
return attributedString
}
private func namesEducationConfig(type: SafetyTipsType) -> CVLabelConfig {
CVLabelConfig(
text: .attributedText(
.composed(of: [
NSAttributedString.with(
image: self.namesEducationIcon(type: type),
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: Self.mutualGroupsFont,
heightReference: .pointSize,
),
SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
self.underlinedNamesEducationString(type: type),
]).styled(with: .alignment(.center)),
),
displayConfig: .forUnstyledText(
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
),
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
numberOfLines: 0,
)
}
private func mutualGroupsLabelConfig(attributedText: NSAttributedString) -> CVLabelConfig {
CVLabelConfig(
text: .attributedText(attributedText),
displayConfig: .forUnstyledText(
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
),
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center,
)
}
private func safetyTipsConfig() -> CVLabelConfig {
CVLabelConfig.unstyledText(
OWSLocalizedString(
"SAFETY_TIPS_BUTTON_ACTION_TITLE",
comment: "Title for Safety Tips button in thread details.",
),
font: UIFont.dynamicTypeCaption1.medium(),
textColor: Theme.isDarkThemeEnabled ? .ows_white : .ows_black,
)
}
private func groupDescriptionTextLabelConfig(text: String) -> CVLabelConfig {
CVLabelConfig.unstyledText(
text,
font: .dynamicTypeSubheadline,
textColor: Theme.primaryTextColor,
numberOfLines: 2,
lineBreakMode: .byTruncatingTail,
textAlignment: .center,
)
}
private static let avatarSizeClass = ConversationAvatarView.Configuration.SizeClass.eightyEight
private var avatarSizeClass: ConversationAvatarView.Configuration.SizeClass { Self.avatarSizeClass }
static func buildComponentState(
thread: TSThread,
transaction: DBReadTransaction,
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(
avatarDataSource: nil,
isAvatarBlurred: false,
isAvatarBeingDownloaded: false,
titleText: TSGroupThread.defaultGroupName,
shouldShowVerifiedBadge: false,
bioText: nil,
safetySection: nil,
groupDescriptionText: nil,
)
}
}
private static func buildComponentState(
contactThread: TSContactThread,
transaction: DBReadTransaction,
avatarBuilder: CVAvatarBuilder,
) -> CVComponentState.ThreadDetails {
let avatarDataSource = avatarBuilder.buildAvatarDataSource(
forAddress: contactThread.contactAddress,
includingBadge: true,
localUserDisplayMode: .noteToSelf,
diameterPoints: avatarSizeClass.diameter,
)
let contactManager = SSKEnvironment.shared.contactManagerImplRef
let isAvatarBlurred = contactManager.shouldBlurContactAvatar(
address: contactThread.contactAddress,
tx: transaction,
)
let isAvatarBeingDownloaded = contactManager.avatarAddressesToShowDownloadingSpinner.contains(contactThread.contactAddress)
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(
for: contactThread.contactAddress,
tx: transaction,
)
let titleText = { () -> String in
if contactThread.isNoteToSelf {
return MessageStrings.noteToSelf
} else {
return displayName.resolvedValue()
}
}()
let shouldShowVerifiedBadge = contactThread.isNoteToSelf
let bioText = { () -> String? in
if contactThread.isNoteToSelf {
return nil
}
let profileManager = SSKEnvironment.shared.profileManagerRef
let userProfile = profileManager.userProfile(for: contactThread.contactAddress, tx: transaction)
return userProfile?.bioForDisplay
}()
let safetySection = Self.buildContactSafetySection(
for: displayName,
in: contactThread,
tx: transaction,
)
return CVComponentState.ThreadDetails(
avatarDataSource: avatarDataSource,
isAvatarBlurred: isAvatarBlurred,
isAvatarBeingDownloaded: isAvatarBeingDownloaded,
titleText: titleText,
shouldShowVerifiedBadge: shouldShowVerifiedBadge,
bioText: bioText,
safetySection: safetySection,
groupDescriptionText: nil,
)
}
private static func buildComponentState(
groupThread: TSGroupThread,
transaction: DBReadTransaction,
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 avatarDataSource = avatarBuilder.buildAvatarDataSource(
forGroupThread: groupThread,
diameterPoints: avatarSizeClass.diameter,
)
let contactManager = SSKEnvironment.shared.contactManagerImplRef
let isAvatarBlurred = contactManager.shouldBlurGroupAvatar(
groupId: groupThread.groupId,
tx: transaction,
)
let isAvatarBeingDownloaded = contactManager.avatarGroupIdsToShowDownloadingSpinner.contains(groupThread.groupId)
let titleText = groupThread.groupNameOrDefault
let safetySection = Self.buildGroupsSafetySection(
from: groupThread,
tx: transaction,
)
let descriptionText: String? = {
guard let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 else { return nil }
return groupModelV2.descriptionText
}()
return CVComponentState.ThreadDetails(
avatarDataSource: avatarDataSource,
isAvatarBlurred: isAvatarBlurred,
isAvatarBeingDownloaded: isAvatarBeingDownloaded,
titleText: titleText,
shouldShowVerifiedBadge: false,
bioText: nil,
safetySection: safetySection,
groupDescriptionText: descriptionText,
)
}
private let vSpacingTitle: CGFloat = 12
private let vSpacingSubtitle: CGFloat = 2
private let hPaddingSafetySection: CGFloat = 24
private func vSpacingSafetySection(hasWallpaper: Bool) -> CGFloat {
hasWallpaper ? 12 : 16
}
private let minBottomPadding: CGFloat = 4
private var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .fill,
spacing: 0,
layoutMargins: UIEdgeInsets(top: 8, left: 32, bottom: 16, right: 32),
)
}
private var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .center,
spacing: 0,
layoutMargins: UIEdgeInsets(top: 20, leading: 16, bottom: 8, trailing: 16),
)
}
private var groupStackConfig: CVStackViewConfig {
ManualStackView.Config(
axis: .vertical,
alignment: .center,
spacing: 12,
layoutMargins: .init(hMargin: hPaddingSafetySection, vMargin: 16),
)
}
private static let measurementKey_outerStack = "CVComponentThreadDetails.measurementKey_outerStack"
private static let measurementKey_innerStack = "CVComponentThreadDetails.measurementKey_innerStack"
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
var innerSubviewInfos = [ManualStackSubviewInfo]()
let maxContentWidth = maxWidth - (
outerStackConfig.layoutMargins.totalWidth +
innerStackConfig.layoutMargins.totalWidth
)
innerSubviewInfos.append(avatarSizeClass.size.asManualSubviewInfo)
innerSubviewInfos.append(CGSize(square: vSpacingTitle).asManualSubviewInfo)
let titleSize = CVText.measureLabel(config: titleLabelConfig, maxWidth: maxContentWidth)
innerSubviewInfos.append(titleSize.asManualSubviewInfo)
if let bioText = self.bioText {
let bioSize = CVText.measureLabel(
config: bioLabelConfig(text: bioText),
maxWidth: maxContentWidth,
)
innerSubviewInfos.append(CGSize(square: vSpacingSubtitle).asManualSubviewInfo)
innerSubviewInfos.append(bioSize.asManualSubviewInfo)
}
if let groupDescriptionText = self.groupDescriptionText {
var groupDescriptionSize = CVText.measureLabel(
config: groupDescriptionTextLabelConfig(text: groupDescriptionText),
maxWidth: maxContentWidth,
)
groupDescriptionSize.width = maxContentWidth
innerSubviewInfos.append(groupDescriptionSize.asManualSubviewInfo(hasFixedWidth: true))
}
let maxGroupWidth = maxContentWidth - hPaddingSafetySection * 2
var groupInfoSubviewInfos = [ManualStackSubviewInfo]()
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowLowTrustWarning {
let reviewCarefullySize = CVText.measureLabel(
config: self.reviewCarefullyConfig(),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(reviewCarefullySize.asManualSubviewInfo)
}
innerSubviewInfos.append(CGSize(square: vSpacingSafetySection(hasWallpaper: conversationStyle.hasWallpaper)).asManualSubviewInfo)
let mutualGroupsSize: CGSize
if conversationStyle.hasWallpaper {
innerSubviewInfos.append(CGSize(width: maxContentWidth - 16, height: 1).asManualSubviewInfo)
}
if safetySection.shouldShowProfileNamesEducation {
let size = CVText.measureLabel(
config: self.namesEducationConfig(type: safetySection.threadType),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
if let detailsText = safetySection.detailsText {
let size = CVText.measureLabel(
config: mutualGroupsLabelConfig(attributedText: detailsText),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
if let mutualGroupsText = safetySection.mutualGroupsText {
let groupLabelSize = CVText.measureLabel(
config: mutualGroupsLabelConfig(attributedText: mutualGroupsText),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(groupLabelSize.asManualSubviewInfo)
}
if safetySection.shouldShowSafetyTipsButton {
let safetyTipSize = CVText.measureLabel(
config: safetyTipsConfig(),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(safetyTipSize.asManualSubviewInfo)
}
mutualGroupsSize = ManualStackView.measure(
config: groupStackConfig,
subviewInfos: groupInfoSubviewInfos,
).measuredSize
innerSubviewInfos.append(mutualGroupsSize.asManualSubviewInfo)
} else {
innerSubviewInfos.append(CGSize(square: minBottomPadding).asManualSubviewInfo)
}
let innerStackMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: innerSubviewInfos,
)
let outerSubviewInfos = [innerStackMeasurement.measuredSize.asManualSubviewInfo]
let outerStackMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: outerSubviewInfos,
maxWidth: maxWidth,
)
return outerStackMeasurement.measuredSize
}
// MARK: - Events
override public func handleTap(
sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem,
) -> Bool {
guard let componentView = componentView as? CVComponentViewThreadDetails else {
owsFailDebug("Unexpected componentView.")
return false
}
if
canTapTitle,
let contactThread = thread as? TSContactThread,
componentView.titleButton.bounds.contains(sender.location(in: componentView.titleButton))
{
componentDelegate.didTapContactName(thread: contactThread)
return true
}
if let safetySection = threadDetails.safetySection {
if
safetySection.shouldShowSafetyTipsButton,
componentView.showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
{
didShowTips(type: safetySection.threadType)
return true
}
if
safetySection.threadType == .group,
safetySection.detailsText != nil,
componentView.detailsButton.bounds.contains(sender.location(in: componentView.detailsButton))
{
componentDelegate.didTapShowConversationSettings()
return true
}
if
safetySection.shouldShowProfileNamesEducation,
componentView.profileNamesEducationLabel.bounds.contains(sender.location(in: componentView.profileNamesEducationLabel))
{
componentDelegate.didTapNameEducation(type: safetySection.threadType)
return true
}
}
if threadDetails.isAvatarBlurred {
guard let avatarView = componentView.avatarView else {
owsFailDebug("Missing avatarView.")
return false
}
let location = sender.location(in: avatarView)
if avatarView.bounds.contains(location) {
let contactManager = SSKEnvironment.shared.contactManagerImplRef
contactManager.didTapToUnblurAvatar(for: thread)
return true
}
}
return false
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
public class CVComponentViewThreadDetails: NSObject, CVComponentView {
fileprivate var avatarView: ConversationAvatarView?
fileprivate let titleLabel = CVLabel()
fileprivate let titleButton = CVButton()
fileprivate let bioLabel = CVLabel()
fileprivate let reviewCarefullyLabel = CVLabel()
fileprivate let profileNamesEducationLabel = CVButton()
fileprivate let detailsButton = CVButton()
fileprivate let mutualGroupsLabel = CVLabel()
fileprivate let showTipsButton = OWSRoundedButton()
fileprivate let groupDescriptionPreviewView = GroupDescriptionPreviewView(
shouldDeactivateConstraints: true,
)
fileprivate let outerStackView = ManualStackView(name: "Thread details outer")
fileprivate let innerStackView = ManualStackView(name: "Thread details inner")
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView = self.wallpaperBlurView {
return wallpaperBlurView
}
let wallpaperBlurView = CVWallpaperBlurView()
self.wallpaperBlurView = wallpaperBlurView
return wallpaperBlurView
}
public var isDedicatedCellView = false
public var rootView: UIView {
outerStackView
}
// MARK: -
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
outerStackView.reset()
innerStackView.reset()
titleLabel.text = nil
titleButton.reset()
bioLabel.text = nil
reviewCarefullyLabel.text = nil
profileNamesEducationLabel.reset()
detailsButton.reset()
mutualGroupsLabel.text = nil
groupDescriptionPreviewView.descriptionText = nil
avatarView = nil
wallpaperBlurView?.removeFromSuperview()
}
}
}
extension CVComponentThreadDetails {
private func didShowTips(type: SafetyTipsType) {
let viewController = SafetyTipsViewController(type: type)
UIApplication.shared.frontmostViewController?.present(viewController, animated: true)
}
private static func buildGroupsSafetySection(
from groupThread: TSGroupThread,
tx: DBReadTransaction,
) -> CVComponentState.ThreadDetails.SafetySection {
let accountManager = DependenciesBridge.shared.tsAccountManager
let groupMembership = groupThread.groupModel.groupMembership
var members = groupMembership.fullMembers
let localUserIsAMember: Bool
if let localIdentifiers = accountManager.localIdentifiers(tx: tx) {
// Remove yourself because we don't want to show your display name
let removedMember = members.remove(localIdentifiers.aciAddress)
localUserIsAMember = removedMember != nil
} else {
localUserIsAMember = false
}
let sortedMemberNames = SSKEnvironment.shared.contactManagerImplRef
.sortedComparableNames(for: members, tx: tx)
.map { $0.displayName.resolvedValue() }
let formatString: String
var underlinedPortion: String?
var arguments: [CVarArg] = sortedMemberNames
switch (sortedMemberNames.count, localUserIsAMember) {
case (0, _):
formatString = OWSLocalizedString(
"THREAD_DETAILS_NO_MEMBERS",
comment: "Label for a group with no members or no members but yourself",
)
case (1, false):
formatString = OWSLocalizedString(
"THREAD_DETAILS_ONE_MEMBER",
comment: "Label for a group with one member (not counting yourself), displaying their name",
)
case (1, true):
formatString = OWSLocalizedString(
"THREAD_DETAILS_ONE_MEMBER_AND_YOURSELF",
comment: "Label for a group you are in with one other member, listing their name and yourself",
)
case (2, false):
formatString = OWSLocalizedString(
"THREAD_DETAILS_TWO_MEMBERS",
comment: "Label for a group you are not in which has two members, listing their names",
)
case (2, true):
formatString = OWSLocalizedString(
"THREAD_DETAILS_TWO_MEMBERS_AND_YOURSELF",
comment: "Label for a group you are in which has two other members, listing their names and yourself",
)
case (3, false):
formatString = OWSLocalizedString(
"THREAD_DETAILS_THREE_MEMBERS",
comment: "Label for a group you are not in which has three members, listing their names",
)
case (3, true):
formatString = OWSLocalizedString(
"THREAD_DETAILS_THREE_MEMBERS_AND_YOURSELF",
comment: "Label for a group you are in which has three other members, listing their names and yourself",
)
case (4, false):
formatString = OWSLocalizedString(
"THREAD_DETAILS_FOUR_MEMBERS",
comment: "Label for a group you are not in which has four members, listing their names",
)
default:
formatString = OWSLocalizedString(
"THREAD_DETAILS_MANY_MEMBERS",
comment: "Label for a group with more than four members, listing the first three members' names and embedding THREAD_DETAILS_OTHER_MEMBERS_COUNT_%ld as a count of other members",
)
let otherMembersFormat = OWSLocalizedString(
"THREAD_DETAILS_OTHER_MEMBERS_COUNT_%ld",
tableName: "PluralAware",
comment: "The number of other members in a group. Embedded into the last parameter of THREAD_DETAILS_MANY_MEMBERS",
)
let firstThreeMembers = Array(arguments.prefix(3))
let remainingMembersCount = sortedMemberNames.count + (localUserIsAMember ? 1 : 0) - firstThreeMembers.count
let otherMembersString = String.localizedStringWithFormat(otherMembersFormat, remainingMembersCount)
underlinedPortion = otherMembersString
arguments = firstThreeMembers + [otherMembersString]
}
let membersString = String(
format: formatString,
arguments: arguments,
)
let membersAttributedString: NSAttributedString
if let underlinedPortion {
let underlinedRange = NSString(string: membersString).range(of: underlinedPortion)
let attributedString = NSMutableAttributedString(string: membersString)
attributedString.addAttributes(
[
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: Self.underlineColor,
],
range: underlinedRange,
)
membersAttributedString = attributedString
} else {
membersAttributedString = NSAttributedString(string: membersString)
}
let membersAttributedText = NSAttributedString.composed(of: [
NSAttributedString.with(
image: UIImage(named: "group-resizable")!,
font: Self.mutualGroupsFont,
),
" ",
membersAttributedString,
]).styled(
with: .font(Self.mutualGroupsFont),
.color(Self.mutualGroupsTextColor),
)
let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustGroup(groupThread: groupThread, tx: tx)
return .init(
shouldShowLowTrustWarning: shouldShowUnknownThreadWarning,
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
detailsText: membersAttributedText,
mutualGroupsText: nil,
threadType: .group,
shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx),
)
}
private static func buildContactSafetySection(
for displayName: DisplayName,
in contactThread: TSContactThread,
tx: DBReadTransaction,
) -> CVComponentState.ThreadDetails.SafetySection? {
switch displayName {
case .nickname, .systemContactName, .profileName:
break
case .phoneNumber, .username, .deletedAccount, .unknown:
// If the display name is a phone number or username, you started a
// conversation with them and don't yet have a profile name, so we
// don't need to show name-related info.
return nil
}
guard !contactThread.isNoteToSelf else {
return .init(
shouldShowLowTrustWarning: false,
shouldShowProfileNamesEducation: false,
detailsText: nil,
mutualGroupsText: OWSLocalizedString(
"THREAD_DETAILS_NOTE_TO_SELF_EXPLANATION",
comment: "Subtitle appearing at the top of the users 'note to self' conversation",
).styled(
with: .font(.dynamicTypeSubheadline),
.color(UIColor.Signal.label),
),
threadType: .contact,
shouldShowSafetyTipsButton: false,
)
}
let groupThreads = TSGroupThread.groupThreads(with: contactThread.contactAddress, transaction: tx)
let mutualGroupNames = groupThreads.filter { $0.groupModel.groupMembership.isLocalUserFullMember && $0.shouldThreadBeVisible && !$0.isTerminatedGroup }.map { $0.groupNameOrDefault }
let isMessageRequest = contactThread.hasPendingMessageRequest(transaction: tx)
let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustContact(
contactThread: contactThread,
tx: tx,
)
// We need these to be CVarArgs for them to format appropriately.
let groupNamesFormatArg: [CVarArg] = mutualGroupNames
let formattedString: String
switch mutualGroupNames.count {
case 0:
formattedString = String(
format: OWSLocalizedString(
"THREAD_DETAILS_ZERO_MUTUAL_GROUPS",
comment: "A string indicating there are no mutual groups the user shares with this contact",
),
groupNamesFormatArg,
)
case 1:
formattedString = String(
format: OWSLocalizedString(
"THREAD_DETAILS_ONE_MUTUAL_GROUP",
comment: "A string indicating a mutual group the user shares with this contact. Embeds {{mutual group name}}",
),
groupNamesFormatArg,
)
case 2:
formattedString = String(
format: OWSLocalizedString(
"THREAD_DETAILS_TWO_MUTUAL_GROUP",
comment: "A string indicating two mutual groups the user shares with this contact. Embeds {{mutual group name}}",
),
groupNamesFormatArg,
)
case 3:
formattedString = String(
format: OWSLocalizedString(
"THREAD_DETAILS_THREE_MUTUAL_GROUP",
comment: "A string indicating three mutual groups the user shares with this contact. Embeds {{mutual group name}}",
),
groupNamesFormatArg,
)
default:
// For this string, we want to use the first two groups' names
// and add a final format arg for the number of remaining
// groups.
let firstTwoGroups = Array(mutualGroupNames[0..<2])
let remainingGroupsCount = mutualGroupNames.count - firstTwoGroups.count
let formatArgs: [CVarArg] = firstTwoGroups + [remainingGroupsCount]
formattedString = String.localizedStringWithFormat(
OWSLocalizedString(
"THREAD_DETAILS_MORE_MUTUAL_GROUP_%3$ld",
tableName: "PluralAware",
comment: "A string indicating two mutual groups the user shares with this contact and that there are more unlisted. Embeds {{group name, group name, number of other groups}}",
),
formatArgs,
)
}
// In order for the phone number to appear in the same box as the
// mutual groups, it needs to be part of the same label.
let phoneNumberString: NSAttributedString? = {
if case .phoneNumber = displayName {
return nil
}
let phoneNumber = contactThread.contactAddress.phoneNumber
let formattedPhoneNumber = phoneNumber.map(PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(_:))
guard let formattedPhoneNumber else {
return nil
}
return NSAttributedString.composed(of: [
NSAttributedString.with(image: Theme.iconImage(.contactInfoPhone), font: Self.mutualGroupsFont),
" ",
formattedPhoneNumber,
])
}()
let isPhoneContact = phoneNumberString != nil
let shouldShowProfileNamesEducation = if isPhoneContact {
false
} else if case .nickname = displayName {
false
} else {
true
}
return .init(
shouldShowLowTrustWarning: shouldShowUnknownThreadWarning,
shouldShowProfileNamesEducation: shouldShowProfileNamesEducation,
detailsText: phoneNumberString,
mutualGroupsText: NSAttributedString.composed(of: [
NSAttributedString.with(
image: UIImage(named: "group-resizable")!,
font: Self.mutualGroupsFont,
),
" ",
formattedString,
]),
threadType: .contact,
shouldShowSafetyTipsButton: isMessageRequest,
)
}
}