// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit 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 detailsText: String? { threadDetails.detailsText } private var mutualGroupsText: NSAttributedString? { threadDetails.mutualGroupsText } private var shouldShowSafetyTip: Bool { threadDetails.mutualGroupsTapAction != nil } 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() } public override 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 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) 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: [ unblurAvatarIconView, unblurAvatarLabel ]) avatarWrapper.addSubviewToCenterOnSuperview(unblurAvatarStack, size: unblurAvatarStackMeasurement.measuredSize) } else { innerViews.append(avatarView) } innerViews.append(UIView.spacer(withHeight: 1)) if conversationStyle.hasWallpaper { let wallpaperBlurView = componentView.ensureWallpaperBlurView() configureWallpaperBlurView( wallpaperBlurView: wallpaperBlurView, maskCornerRadius: 24, componentDelegate: componentDelegate ) 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 detailsText = self.detailsText { let detailsLabel = componentView.detailsLabel detailsLabelConfig(text: detailsText).applyForRendering(label: detailsLabel) innerViews.append(UIView.spacer(withHeight: vSpacingSubtitle)) innerViews.append(detailsLabel) } if let groupDescriptionText = self.groupDescriptionText { let groupDescriptionPreviewView = componentView.groupDescriptionPreviewView let config = groupDescriptionTextLabelConfig(text: groupDescriptionText) groupDescriptionPreviewView.apply(config: config) groupDescriptionPreviewView.groupName = titleText innerViews.append(UIView.spacer(withHeight: vSpacingMutualGroups)) innerViews.append(groupDescriptionPreviewView) } if let mutualGroupsText = self.mutualGroupsText { let mutualGroupsLabel = componentView.mutualGroupsLabel let showTipsButton = componentView.showTipsButton let groupInfoWrapper = ManualLayoutViewWithLayer(name: "groupWrapper") var groupInfoSubviewInfos = [ManualStackSubviewInfo]() innerViews.append(UIView.spacer(withHeight: vSpacingMutualGroups)) 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 - (mutualGroupsPadding * 2) let mutualGroupsLabelConfig = mutualGroupsLabelConfig(attributedText: mutualGroupsText) mutualGroupsLabelConfig.applyForRendering(label: mutualGroupsLabel) let mutualGroupsLabelSize = CVText.measureLabel(config: mutualGroupsLabelConfig, maxWidth: maxWidth) groupInfoSubviewInfos.append(mutualGroupsLabelSize.asManualSubviewInfo) var groupInfoSubviews: [UIView] = [ mutualGroupsLabel ] if self.shouldShowSafetyTip { groupInfoSubviews.append(showTipsButton) let safetyButtonLabelConfig = safetyTipsConfig() safetyButtonLabelConfig.applyForRendering(button: showTipsButton) showTipsButton.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray60 : .ows_gray05 showTipsButton.contentEdgeInsets = .init(hMargin: 12.0, vMargin: 8.0) showTipsButton.dimsWhenHighlighted = true showTipsButton.block = { [weak self] in self?.didShowTips() } 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 ) } 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 let vSpacingSubtitle: CGFloat = 2 private let vSpacingMutualGroups: CGFloat = 16 private let mutualGroupsPadding: CGFloat = 24 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, let chevron = UIImage(named: "chevron-right-20") { attributedString.append(.with( image: chevron, font: .systemFont(ofSize: 24), attributes: [ .foregroundColor: Theme.primaryIconColor ], centerVerticallyRelativeTo: font, heightReference: .pointSize )) } return CVLabelConfig.init( 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 = { 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 func detailsLabelConfig(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 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: 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( avatarDataSource: nil, isAvatarBlurred: false, titleText: TSGroupThread.defaultGroupName, shouldShowVerifiedBadge: false, bioText: nil, detailsText: nil, mutualGroupsText: nil, mutualGroupsTapAction: nil, groupDescriptionText: nil ) } } private static func buildComponentState( contactThread: TSContactThread, transaction: SDSAnyReadTransaction, avatarBuilder: CVAvatarBuilder ) -> CVComponentState.ThreadDetails { let avatarDataSource = avatarBuilder.buildAvatarDataSource( forAddress: contactThread.contactAddress, includingBadge: true, localUserDisplayMode: .noteToSelf, diameterPoints: avatarSizeClass.diameter ) let isAvatarBlurred = contactsManagerImpl.shouldBlurContactAvatar( contactThread: contactThread, transaction: transaction ) let displayName = Self.contactsManager.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 } return Self.profileManagerImpl.profileBioForDisplay(for: contactThread.contactAddress, transaction: transaction) }() let detailsText = { () -> String? in guard contactThread.isNoteToSelf else { return nil } return OWSLocalizedString( "THREAD_DETAILS_NOTE_TO_SELF_EXPLANATION", comment: "Subtitle appearing at the top of the users 'note to self' conversation" ) }() let (mutualGroupsText, shouldShowSafetyTip) = Self.buildMutualGroupsContactString( for: displayName, in: contactThread, tx: transaction ) return CVComponentState.ThreadDetails( avatarDataSource: avatarDataSource, isAvatarBlurred: isAvatarBlurred, titleText: titleText, shouldShowVerifiedBadge: shouldShowVerifiedBadge, bioText: bioText, detailsText: detailsText, mutualGroupsText: mutualGroupsText, mutualGroupsTapAction: shouldShowSafetyTip ? .showContactSafetyTip : nil, groupDescriptionText: nil ) } 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 avatarDataSource = avatarBuilder.buildAvatarDataSource( forGroupThread: groupThread, diameterPoints: avatarSizeClass.diameter) let isAvatarBlurred = contactsManagerImpl.shouldBlurGroupAvatar( groupThread: groupThread, transaction: transaction ) let titleText = groupThread.groupNameOrDefault let detailsText = { () -> String? in if let groupModelV2 = groupThread.groupModel as? TSGroupModelV2, groupModelV2.isJoinRequestPlaceholder { // Don't show details for a placeholder. return nil } let memberCount = groupThread.groupModel.groupMembership.fullMembers.count return GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount) }() let mutualGroupsText = Self.buildMutualGroupsGroupString( 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, titleText: titleText, shouldShowVerifiedBadge: false, bioText: nil, detailsText: detailsText, mutualGroupsText: mutualGroupsText, mutualGroupsTapAction: mutualGroupsText == nil ? nil : .showGroupSafetyTip, groupDescriptionText: descriptionText ) } 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: 3, layoutMargins: UIEdgeInsets(top: 20, leading: 16, bottom: 24, trailing: 16) ) } private var groupStackConfig: CVStackViewConfig { ManualStackView.Config( axis: .vertical, alignment: .center, spacing: 12, layoutMargins: .init(hMargin: mutualGroupsPadding, vMargin: 24.0) ) } 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: 1).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 detailsText = self.detailsText { let detailsSize = CVText.measureLabel(config: detailsLabelConfig(text: detailsText), maxWidth: maxContentWidth) innerSubviewInfos.append(CGSize(square: vSpacingSubtitle).asManualSubviewInfo) innerSubviewInfos.append(detailsSize.asManualSubviewInfo) } if let groupDescriptionText = self.groupDescriptionText { var groupDescriptionSize = CVText.measureLabel( config: groupDescriptionTextLabelConfig(text: groupDescriptionText), maxWidth: maxContentWidth ) groupDescriptionSize.width = maxContentWidth innerSubviewInfos.append(CGSize(square: vSpacingMutualGroups).asManualSubviewInfo) innerSubviewInfos.append(groupDescriptionSize.asManualSubviewInfo(hasFixedWidth: true)) } if let mutualGroupsText = self.mutualGroupsText { innerSubviewInfos.append(CGSize(square: vSpacingMutualGroups).asManualSubviewInfo) let mutualGroupsSize: CGSize if conversationStyle.hasWallpaper { innerSubviewInfos.append(CGSize(width: maxContentWidth - 16, height: 1).asManualSubviewInfo) } let maxGroupWidth = maxContentWidth - mutualGroupsPadding * 2 var groupInfoSubviewInfos = [ManualStackSubviewInfo]() let groupLabelSize = CVText.measureLabel( config: mutualGroupsLabelConfig(attributedText: mutualGroupsText), maxWidth: maxGroupWidth ) groupInfoSubviewInfos.append(groupLabelSize.asManualSubviewInfo) if self.shouldShowSafetyTip { let safetyTipSize = CVText.measureLabel( config: safetyTipsConfig(), maxWidth: maxGroupWidth ) groupInfoSubviewInfos.append(safetyTipSize.asManualSubviewInfo) } mutualGroupsSize = ManualStackView.measure( config: groupStackConfig, subviewInfos: groupInfoSubviewInfos ).measuredSize innerSubviewInfos.append(mutualGroupsSize.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 public override func handleTap( sender: UITapGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem ) -> Bool { guard let componentView = componentView as? CVComponentViewThreadDetails else { owsFailDebug("Unexpected componentView.") return false } 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) { Self.databaseStorage.write { transaction in if let contactThread = self.thread as? TSContactThread { Self.contactsManagerImpl.doNotBlurContactAvatar(address: contactThread.contactAddress, transaction: transaction) } else if let groupThread = self.thread as? TSGroupThread { Self.contactsManagerImpl.doNotBlurGroupAvatar(groupThread: groupThread, transaction: transaction) } else { owsFailDebug("Invalid 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 detailsLabel = CVLabel() 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 detailsLabel.text = nil mutualGroupsLabel.text = nil groupDescriptionPreviewView.descriptionText = nil avatarView = nil wallpaperBlurView?.removeFromSuperview() wallpaperBlurView?.resetContentAndConfiguration() } } } extension CVComponentThreadDetails { private func didShowTips() { let type: SafetyTipsType = { switch threadDetails.mutualGroupsTapAction { case .none, .showContactSafetyTip: return .contact case .showGroupSafetyTip: return .group } }() let viewController = SafetyTipsViewController(type: type) UIApplication.shared.frontmostViewController?.present(viewController, animated: true) } private static func buildMutualGroupsGroupString( from groupThread: TSGroupThread, tx: SDSAnyReadTransaction ) -> NSAttributedString? { guard contactsManagerImpl.shouldShowUnknownThreadWarning( thread: groupThread, transaction: tx ) else { return nil } return NSAttributedString.composed(of: [ NSAttributedString.with( image: UIImage(named: "error-circle-20")!, font: Self.mutualGroupsFont ), " ", OWSLocalizedString( "SYSTEM_MESSAGE_UNKNOWN_THREAD_WARNING_GROUP", comment: "Indicator warning about an unknown group thread." ) ]) } private static func buildMutualGroupsContactString( for displayName: DisplayName, in contactThread: TSContactThread, tx: SDSAnyReadTransaction ) -> (NSAttributedString?, Bool) { var shouldShowSafetyTip = false guard !contactThread.contactAddress.isLocalAddress else { // Don't show mutual groups for "Note to Self". return (nil, shouldShowSafetyTip) } let groupThreads = TSGroupThread.groupThreads(with: contactThread.contactAddress, transaction: tx) let mutualGroupNames = groupThreads.filter { $0.isLocalUserFullMember && $0.shouldThreadBeVisible }.map { $0.groupNameOrDefault } let formatString: String var formatArgs: [AttributedFormatArg] = mutualGroupNames.map { name in return .string(name, attributes: [.font: Self.mutualGroupsFont.semibold()]) } switch mutualGroupNames.count { case 0: guard contactsManagerImpl.shouldShowUnknownThreadWarning( thread: contactThread, transaction: tx ) else { return (nil, shouldShowSafetyTip) } formatString = OWSLocalizedString( "SYSTEM_MESSAGE_UNKNOWN_THREAD_WARNING_CONTACT", comment: "Indicator warning about an unknown contact thread." ) shouldShowSafetyTip = true case 1: formatString = OWSLocalizedString( "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 = OWSLocalizedString( "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 = OWSLocalizedString( "THREAD_DETAILS_THREE_MUTUAL_GROUP", comment: "A string indicating three mutual groups the user shares with this contact. Embeds {{mutual group name}}" ) default: formatString = OWSLocalizedString( "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}}" ) // 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(formatArgs[0..<2]) let remainingGroupsCount = mutualGroupNames.count - firstTwoGroups.count formatArgs = firstTwoGroups + [.raw(remainingGroupsCount)] } let icon: String if mutualGroupNames.isEmpty { icon = "error-circle-20" } else { icon = "group-resizable" } // 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 NSAttributedString() } let phoneNumber = contactThread.contactAddress.phoneNumber let formattedPhoneNumber = phoneNumber.map(PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber:)) guard let formattedPhoneNumber else { return NSAttributedString() } return NSAttributedString.composed(of: [ NSAttributedString.with(image: Theme.iconImage(.contactInfoPhone), font: Self.mutualGroupsFont), " ", formattedPhoneNumber, "\n" ]).styled( with: .paragraphSpacingAfter(Self.mutualGroupsFont.lineHeight * 0.5), .alignment(.center) ) }() return (NSAttributedString.composed(of: [ phoneNumberString, // Will be empty if unknown NSAttributedString.with( image: UIImage(named: icon)!, font: Self.mutualGroupsFont ), " ", NSAttributedString.make( fromFormat: formatString, attributedFormatArgs: formatArgs ) ]), shouldShowSafetyTip) } }