// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import UIKit struct ConversationHeaderBuilder: Dependencies { weak var delegate: ConversationHeaderDelegate! let transaction: SDSAnyReadTransaction let avatarSize: UInt let options: Options var subviews = [UIView]() struct Options: OptionSet { let rawValue: Int static let message = Options(rawValue: 1 << 0) static let audioCall = Options(rawValue: 1 << 1) static let videoCall = Options(rawValue: 1 << 2) static let mute = Options(rawValue: 1 << 3) static let search = Options(rawValue: 1 << 4) static let renderLocalUserAsNoteToSelf = Options(rawValue: 1 << 5) } static func buildHeader( for thread: TSThread, avatarSize: UInt, options: Options, delegate: ConversationHeaderDelegate ) -> UIView { if let groupThread = thread as? TSGroupThread { return ConversationHeaderBuilder.buildHeaderForGroup( groupThread: groupThread, avatarSize: avatarSize, options: options, delegate: delegate ) } else if let contactThread = thread as? TSContactThread { return ConversationHeaderBuilder.buildHeaderForContact( contactThread: contactThread, avatarSize: avatarSize, options: options, delegate: delegate ) } else { owsFailDebug("Invalid thread.") return UIView() } } static func buildHeaderForGroup( groupThread: TSGroupThread, avatarSize: UInt, options: Options, delegate: ConversationHeaderDelegate ) -> UIView { // Make sure the view is loaded before we open a transaction, // because it can end up creating a transaction within. _ = delegate.view return databaseStorage.read { transaction in self.buildHeaderForGroup( groupThread: groupThread, avatarSize: avatarSize, options: options, delegate: delegate, transaction: transaction ) } } static func buildHeaderForGroup( groupThread: TSGroupThread, avatarSize: UInt, options: Options, delegate: ConversationHeaderDelegate, transaction: SDSAnyReadTransaction ) -> UIView { var builder = ConversationHeaderBuilder( delegate: delegate, avatarSize: avatarSize, options: options, transaction: transaction ) var isShowingGroupDescription = false if let groupModel = groupThread.groupModel as? TSGroupModelV2 { if let descriptionText = groupModel.descriptionText { isShowingGroupDescription = true builder.addGroupDescriptionPreview(text: descriptionText) } else if delegate.canEditConversationAttributes { isShowingGroupDescription = true builder.addCreateGroupDescriptionButton() } } if !isShowingGroupDescription && !groupThread.groupModel.isPlaceholder { let memberCount = groupThread.groupModel.groupMembership.fullMembers.count var groupMembersText = GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount) if groupThread.isGroupV1Thread { groupMembersText.append(" ") groupMembersText.append("•") groupMembersText.append(" ") groupMembersText.append(NSLocalizedString("GROUPS_LEGACY_GROUP_INDICATOR", comment: "Label indicating a legacy group.")) } builder.addSubtitleLabel(text: groupMembersText) } if groupThread.isGroupV1Thread { builder.addLegacyGroupView(groupThread: groupThread) } builder.addButtons() return builder.build() } static func buildHeaderForContact( contactThread: TSContactThread, avatarSize: UInt, options: Options, delegate: ConversationHeaderDelegate ) -> UIView { // Make sure the view is loaded before we open a transaction, // because it can end up creating a transaction within. _ = delegate.view return databaseStorage.read { transaction in self.buildHeaderForContact( contactThread: contactThread, avatarSize: avatarSize, options: options, delegate: delegate, transaction: transaction ) } } static func buildHeaderForContact( contactThread: TSContactThread, avatarSize: UInt, options: Options, delegate: ConversationHeaderDelegate, transaction: SDSAnyReadTransaction ) -> UIView { var builder = ConversationHeaderBuilder( delegate: delegate, avatarSize: avatarSize, options: options, transaction: transaction ) if !contactThread.contactAddress.isLocalAddress, let bioText = profileManagerImpl.profileBioForDisplay( for: contactThread.contactAddress, transaction: transaction ) { let label = builder.addSubtitleLabel(text: bioText) label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .center } let threadName = delegate.threadName( renderLocalUserAsNoteToSelf: options.contains(.renderLocalUserAsNoteToSelf), transaction: transaction ) let recipientAddress = contactThread.contactAddress if let phoneNumber = recipientAddress.phoneNumber { let formattedPhoneNumber = PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber) if threadName != formattedPhoneNumber { func copyContactPhoneNumber(delegate: ConversationHeaderDelegate?) { guard let delegate = delegate else { owsFailDebug("Missing delegate.") return } UIPasteboard.general.string = recipientAddress.phoneNumber let toast = NSLocalizedString("COPIED_TO_CLIPBOARD", comment: "Indicator that a value has been copied to the clipboard.") delegate.tableViewController.presentToast(text: toast) } let label = builder.addSubtitleLabel(text: formattedPhoneNumber) label.addTapGesture { [weak delegate] in copyContactPhoneNumber(delegate: delegate) } label.addLongPressGesture { [weak delegate] in copyContactPhoneNumber(delegate: delegate) } } } let isVerified = identityManager.verificationState( for: recipientAddress, transaction: transaction ) == .verified if isVerified { let subtitle = NSMutableAttributedString() subtitle.appendTemplatedImage(named: "check-12", font: .ows_dynamicTypeSubheadlineClamped) subtitle.append(" ") subtitle.append(NSLocalizedString("PRIVACY_IDENTITY_IS_VERIFIED_BADGE", comment: "Badge indicating that the user is verified.")) builder.addSubtitleLabel(attributedText: subtitle) } builder.addButtons() return builder.build() } init(delegate: ConversationHeaderDelegate, avatarSize: UInt, options: Options, transaction: SDSAnyReadTransaction) { self.delegate = delegate self.avatarSize = avatarSize self.options = options self.transaction = transaction addFirstSubviews(transaction: transaction) } mutating func addFirstSubviews(transaction: SDSAnyReadTransaction) { let avatarView = buildAvatarView(transaction: transaction) let avatarWrapper = UIView.container() avatarWrapper.addSubview(avatarView) avatarView.autoPinEdgesToSuperviewEdges() let avatarButton = OWSButton { [weak delegate] in delegate?.tappedAvatar() } avatarWrapper.addSubview(avatarButton) avatarButton.autoPinEdgesToSuperviewEdges() subviews.append(avatarWrapper) subviews.append(UIView.spacer(withHeight: 8)) subviews.append(buildThreadNameLabel()) } mutating func addButtons() { var buttons = [UIView]() if options.contains(.message) { buttons.append(buildIconButton( icon: .settingsChats, text: NSLocalizedString( "CONVERSATION_SETTINGS_MESSAGE_BUTTON", comment: "Button to message the chat" ), action: { [weak delegate] in guard let delegate = delegate else { return } delegate.signalApp.presentConversation(for: delegate.thread, action: .compose, animated: true) } )) } if ConversationViewController.canCall(threadViewModel: delegate.threadViewModel) { let isCurrentCallForThread = callService.currentCall?.thread.uniqueId == delegate.thread.uniqueId let hasCurrentCall = callService.currentCall != nil if options.contains(.videoCall) { buttons.append(buildIconButton( icon: .videoCall, text: NSLocalizedString( "CONVERSATION_SETTINGS_VIDEO_CALL_BUTTON", comment: "Button to start a video call" ), isEnabled: isCurrentCallForThread || !hasCurrentCall, action: { [weak delegate] in delegate?.startCall(withVideo: true) } )) } if !delegate.thread.isGroupThread, options.contains(.audioCall) { buttons.append(buildIconButton( icon: .audioCall, text: NSLocalizedString( "CONVERSATION_SETTINGS_AUDIO_CALL_BUTTON", comment: "Button to start a audio call" ), isEnabled: isCurrentCallForThread || !hasCurrentCall, action: { [weak delegate] in delegate?.startCall(withVideo: false) } )) } } if options.contains(.mute) { buttons.append(buildIconButton( icon: .settingsMuted, text: delegate.threadViewModel.isMuted ? NSLocalizedString( "CONVERSATION_SETTINGS_MUTED_BUTTON", comment: "Button to unmute the chat" ) : NSLocalizedString( "CONVERSATION_SETTINGS_MUTE_BUTTON", comment: "Button to mute the chat" ), action: { [weak delegate] in guard let delegate = delegate else { return } ConversationSettingsViewController.showMuteUnmuteActionSheet( for: delegate.threadViewModel, from: delegate ) { [weak delegate] in delegate?.updateTableContents(shouldReload: true) } } )) } if options.contains(.search), !delegate.isBlockedByMigration { buttons.append(buildIconButton( icon: .settingsSearch, text: NSLocalizedString( "CONVERSATION_SETTINGS_SEARCH_BUTTON", comment: "Button to search the chat" ), action: { [weak delegate] in delegate?.tappedConversationSearch() } )) } let spacerWidth: CGFloat = 8 let totalSpacerWidth = CGFloat(buttons.count - 1) * spacerWidth let maxAvailableButtonWidth = delegate.tableViewController.view.width - (delegate.tableViewController.cellOuterInsets.totalWidth + totalSpacerWidth) let minButtonWidth = maxAvailableButtonWidth / 4 var buttonWidth = max(maxIconButtonWidth, minButtonWidth) let needsTwoRows = buttonWidth * CGFloat(buttons.count) > maxAvailableButtonWidth if needsTwoRows { buttonWidth = buttonWidth * 2 } buttons.forEach { $0.autoSetDimension(.width, toSize: buttonWidth) } func addButtonRow(_ buttons: [UIView]) { let stackView = UIStackView() stackView.axis = .horizontal stackView.distribution = .fillEqually stackView.spacing = spacerWidth buttons.forEach { stackView.addArrangedSubview($0) } subviews.append(stackView) } subviews.append(.spacer(withHeight: 24)) if needsTwoRows { addButtonRow(Array(buttons.prefix(Int(ceil(CGFloat(buttons.count) / 2))))) subviews.append(.spacer(withHeight: 8)) addButtonRow(buttons.suffix(Int(floor(CGFloat(buttons.count) / 2)))) } else { addButtonRow(buttons) } } private var maxIconButtonWidth: CGFloat = 0 mutating func buildIconButton(icon: ThemeIcon, text: String, isEnabled: Bool = true, action: @escaping () -> Void) -> UIView { let button = OWSButton { [weak delegate] in delegate?.tappedButton() action() } button.dimsWhenHighlighted = true button.isEnabled = isEnabled button.layer.cornerRadius = 10 button.clipsToBounds = true button.setBackgroundImage(UIImage(color: delegate.tableViewController.cellBackgroundColor), for: .normal) button.accessibilityLabel = text let imageView = UIImageView() imageView.setTemplateImageName(Theme.iconName(icon), tintColor: Theme.primaryTextColor) imageView.autoSetDimension(.height, toSize: 24) imageView.contentMode = .scaleAspectFit button.addSubview(imageView) imageView.autoPinWidthToSuperview() imageView.autoPinEdge(toSuperviewEdge: .top, withInset: 8) let label = UILabel() label.font = .ows_dynamicTypeCaption2Clamped label.textColor = Theme.primaryTextColor label.textAlignment = .center label.text = text label.sizeToFit() label.setCompressionResistanceHorizontalHigh() let buttonMinimumWidth = label.width + 24 if maxIconButtonWidth < buttonMinimumWidth { maxIconButtonWidth = buttonMinimumWidth } button.addSubview(label) label.autoPinWidthToSuperview(withMargin: 12) label.autoPinEdge(toSuperviewEdge: .bottom, withInset: 6) label.autoPinEdge(.top, to: .bottom, of: imageView, withOffset: 2) return button } mutating func addGroupDescriptionPreview(text: String) { let previewView = GroupDescriptionPreviewView() previewView.descriptionText = text previewView.groupName = delegate.threadName( renderLocalUserAsNoteToSelf: true, transaction: transaction ) previewView.font = .ows_dynamicTypeSubheadlineClamped previewView.textColor = Theme.secondaryTextAndIconColor previewView.textAlignment = .center previewView.numberOfLines = 2 subviews.append(UIView.spacer(withHeight: hasSubtitleLabel ? 4 : 8)) subviews.append(previewView) hasSubtitleLabel = true } mutating func addCreateGroupDescriptionButton() { let button = OWSButton { [weak delegate] in delegate?.didTapAddGroupDescription() } button.setTitle(NSLocalizedString( "GROUP_DESCRIPTION_PLACEHOLDER", comment: "Placeholder text for 'group description' field." ), for: .normal) button.setTitleColor(Theme.secondaryTextAndIconColor, for: .normal) button.titleLabel?.font = .ows_dynamicTypeSubheadlineClamped subviews.append(UIView.spacer(withHeight: hasSubtitleLabel ? 4 : 8)) subviews.append(button) hasSubtitleLabel = true } func buildAvatarView(transaction: SDSAnyReadTransaction) -> UIView { let localUserDisplayMode: LocalUserDisplayMode = (options.contains(.renderLocalUserAsNoteToSelf) ? .noteToSelf : .asUser) let avatarView = ConversationAvatarView(diameterPoints: avatarSize, localUserDisplayMode: localUserDisplayMode) avatarView.configure(thread: delegate.thread, transaction: transaction) // Track the most recent avatar view. delegate.avatarView = avatarView return avatarView } func buildThreadNameLabel() -> UILabel { let label = UILabel() label.text = delegate.threadName( renderLocalUserAsNoteToSelf: options.contains(.renderLocalUserAsNoteToSelf), transaction: transaction ) label.textColor = Theme.primaryTextColor // TODO: See if design really wants this custom font size. label.font = UIFont.ows_semiboldFont(withSize: UIFont.ows_dynamicTypeTitle1Clamped.pointSize * (13/14)) label.lineBreakMode = .byTruncatingTail return label } @discardableResult mutating func addSubtitleLabel(text: String) -> OWSLabel { addSubtitleLabel(attributedText: NSAttributedString(string: text)) } private var hasSubtitleLabel = false @discardableResult mutating func addSubtitleLabel(attributedText: NSAttributedString) -> OWSLabel { subviews.append(UIView.spacer(withHeight: hasSubtitleLabel ? 4 : 8)) let label = buildHeaderSubtitleLabel(attributedText: attributedText) subviews.append(label) hasSubtitleLabel = true return label } mutating func addLegacyGroupView(groupThread: TSGroupThread) { subviews.append(UIView.spacer(withHeight: 12)) let migrationInfo = GroupsV2Migration.migrationInfoForManualMigration(groupThread: groupThread, transaction: transaction) let legacyGroupView = LegacyGroupView( groupThread: groupThread, migrationInfo: migrationInfo, viewController: delegate ) legacyGroupView.configure() legacyGroupView.backgroundColor = delegate.tableViewController.cellBackgroundColor subviews.append(legacyGroupView) } func buildHeaderSubtitleLabel(attributedText: NSAttributedString) -> OWSLabel { let label = OWSLabel() // Defaults need to be set *before* assigning the attributed text, // or the attributes will get overriden label.textColor = Theme.secondaryTextAndIconColor label.lineBreakMode = .byTruncatingTail label.font = .ows_dynamicTypeSubheadlineClamped label.attributedText = attributedText return label } func build() -> UIView { let header = UIStackView(arrangedSubviews: subviews) header.axis = .vertical header.alignment = .center header.layoutMargins = delegate.tableViewController.cellOuterInsetsWithMargin(bottom: 24) header.isLayoutMarginsRelativeArrangement = true header.isUserInteractionEnabled = true header.accessibilityIdentifier = UIView.accessibilityIdentifier(in: delegate, name: "mainSectionHeader") header.addBackgroundView(withBackgroundColor: delegate.tableViewController.tableBackgroundColor) return header } } // MARK: - protocol ConversationHeaderDelegate: UIViewController, Dependencies { var tableViewController: OWSTableViewController2 { get } var thread: TSThread { get } var threadViewModel: ThreadViewModel { get } func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: SDSAnyReadTransaction) -> String var avatarView: UIImageView? { get set } var isBlockedByMigration: Bool { get } var canEditConversationAttributes: Bool { get } func tappedAvatar() func updateTableContents(shouldReload: Bool) func tappedConversationSearch() func startCall(withVideo: Bool) func tappedButton() func didTapUnblockThread(completion: @escaping () -> Void) func didTapAddGroupDescription() } // MARK: - extension ConversationHeaderDelegate { func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: SDSAnyReadTransaction) -> String { var threadName: String if thread.isNoteToSelf, !renderLocalUserAsNoteToSelf, let localName = profileManager.localFullName() { threadName = localName } else { threadName = contactsManager.displayName(for: thread, transaction: transaction) } if let contactThread = thread as? TSContactThread { if let phoneNumber = contactThread.contactAddress.phoneNumber, phoneNumber == threadName { threadName = PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: phoneNumber) } } return threadName } func startCall(withVideo: Bool) { guard ConversationViewController.canCall(threadViewModel: threadViewModel) else { return owsFailDebug("Tried to start a can when calls are disabled") } guard withVideo || !thread.isGroupThread else { return owsFailDebug("Tried to start an audio only group call") } guard !blockingManager.isThreadBlocked(thread) else { didTapUnblockThread { [weak self] in self?.startCall(withVideo: withVideo) } return } if let currentCall = callService.currentCall { if currentCall.thread.uniqueId == thread.uniqueId { windowManager.returnToCallView() } else { owsFailDebug("Tried to start call while call was ongoing") } } else if let groupThread = thread as? TSGroupThread { // We initiated a call, so if there was a pending message request we should accept it. ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread: thread) GroupCallViewController.presentLobby(thread: groupThread) } else if let contactThread = thread as? TSContactThread { let didShowSNAlert = SafetyNumberConfirmationSheet.presentIfNecessary( address: contactThread.contactAddress, confirmationText: CallStrings.confirmAndCallButtonTitle ) { [weak self] didConfirmIdentity in guard didConfirmIdentity else { return } self?.startCall(withVideo: withVideo) } guard !didShowSNAlert else { return } // We initiated a call, so if there was a pending message request we should accept it. ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread: thread) outboundIndividualCallInitiator.initiateCall(address: contactThread.contactAddress, isVideo: withVideo) } } } extension ConversationSettingsViewController: ConversationHeaderDelegate { var tableViewController: OWSTableViewController2 { self } func buildMainHeader() -> UIView { ConversationHeaderBuilder.buildHeader( for: thread, avatarSize: 88, options: [.videoCall, .audioCall, .mute, .search, .renderLocalUserAsNoteToSelf], delegate: self ) } func tappedButton() {} func didTapAddGroupDescription() { guard let groupThread = thread as? TSGroupThread else { return } let vc = GroupDescriptionViewController( groupModel: groupThread.groupModel, options: [.editable, .updateImmediately] ) vc.descriptionDelegate = self presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true) } } extension ConversationSettingsViewController: GroupDescriptionViewControllerDelegate { func groupDescriptionViewControllerDidComplete(groupDescription: String?) { reloadThreadAndUpdateContent() } } // MARK: - @objc public class OWSLabel: UILabel { // MARK: - Tap public typealias TapBlock = () -> Void private var tapBlock: TapBlock? public func addTapGesture(_ tapBlock: @escaping TapBlock) { AssertIsOnMainThread() owsAssertDebug(self.tapBlock == nil) self.tapBlock = tapBlock isUserInteractionEnabled = true addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap))) } @objc private func didTap() { guard let tapBlock = tapBlock else { owsFailDebug("Missing tapBlock.") return } tapBlock() } // MARK: - Long Press public typealias LongPressBlock = () -> Void private var longPressBlock: LongPressBlock? public func addLongPressGesture(_ longPressBlock: @escaping LongPressBlock) { AssertIsOnMainThread() owsAssertDebug(self.longPressBlock == nil) self.longPressBlock = longPressBlock isUserInteractionEnabled = true addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))) } @objc private func didLongPress(sender: UIGestureRecognizer) { guard sender.state == .began else { return } guard let longPressBlock = longPressBlock else { owsFailDebug("Missing longPressBlock.") return } longPressBlock() } }