Path: blob/main/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
import SignalUI
public import UIKit
@MainActor
struct ConversationHeaderBuilder {
weak var delegate: ConversationHeaderDelegate!
let transaction: DBReadTransaction
let sizeClass: ConversationAvatarView.Configuration.SizeClass
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 let noBackground = Options(rawValue: 1 << 6)
}
static func buildHeader(
for thread: TSThread,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
options: Options,
memberLabel: MemberLabelForRendering?,
delegate: ConversationHeaderDelegate,
) -> UIView {
if let groupThread = thread as? TSGroupThread {
return ConversationHeaderBuilder.buildHeaderForGroup(
groupThread: groupThread,
sizeClass: sizeClass,
options: options,
delegate: delegate,
)
} else if let contactThread = thread as? TSContactThread {
return ConversationHeaderBuilder.buildHeaderForContact(
contactThread: contactThread,
sizeClass: sizeClass,
options: options,
memberLabel: memberLabel,
delegate: delegate,
)
} else {
owsFailDebug("Invalid thread.")
return UIView()
}
}
static func buildHeaderForGroup(
groupThread: TSGroupThread,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
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 SSKEnvironment.shared.databaseStorageRef.read { transaction in
self.buildHeaderForGroup(
groupThread: groupThread,
sizeClass: sizeClass,
options: options,
delegate: delegate,
transaction: transaction,
)
}
}
static func buildHeaderForGroup(
groupThread: TSGroupThread,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
options: Options,
delegate: ConversationHeaderDelegate,
transaction: DBReadTransaction,
) -> UIView {
var isTerminated = false
if let groupModelV2 = groupThread.groupModel as? TSGroupModelV2 {
if groupModelV2.isTerminated {
isTerminated = true
}
}
var builder = ConversationHeaderBuilder(
delegate: delegate,
sizeClass: sizeClass,
options: options,
isTerminatedGroup: isTerminated,
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, !groupModel.isTerminated {
isShowingGroupDescription = true
builder.addCreateGroupDescriptionButton()
}
}
if !isShowingGroupDescription, !groupThread.groupModel.isPlaceholder {
let memberCount = groupThread.groupModel.groupMembership.fullMembers.count
var groupMembersText = GroupViewUtils.formatGroupMembersLabel(memberCount: memberCount, isTerminated: groupThread.isTerminatedGroup)
if groupThread.isGroupV1Thread {
groupMembersText.append(" ")
groupMembersText.append("•")
groupMembersText.append(" ")
groupMembersText.append(OWSLocalizedString(
"GROUPS_LEGACY_GROUP_INDICATOR",
comment: "Label indicating a legacy group.",
))
}
builder.addSubtitleLabel(text: groupMembersText)
}
if groupThread.isGroupV1Thread {
builder.addLegacyGroupView()
}
builder.addButtons()
return builder.build()
}
static func buildHeaderForContact(
contactThread: TSContactThread,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
options: Options,
memberLabel: MemberLabelForRendering?,
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 SSKEnvironment.shared.databaseStorageRef.read { transaction in
self.buildHeaderForContact(
contactThread: contactThread,
sizeClass: sizeClass,
options: options,
memberLabel: memberLabel,
delegate: delegate,
transaction: transaction,
)
}
}
static func buildHeaderForContact(
contactThread: TSContactThread,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
options: Options,
memberLabel: MemberLabelForRendering?,
delegate: ConversationHeaderDelegate,
transaction: DBReadTransaction,
) -> UIView {
var builder = ConversationHeaderBuilder(
delegate: delegate,
sizeClass: sizeClass,
options: options,
isTerminatedGroup: false,
transaction: transaction,
)
if BuildFlags.MemberLabel.display, let memberLabel {
let memberLabelLabel = builder.addMemberLabel(label: memberLabel.label, color: memberLabel.groupNameColor)
memberLabelLabel.numberOfLines = 0
memberLabelLabel.textAlignment = .center
}
let address = contactThread.contactAddress
if !address.isLocalAddress, let bioText = SSKEnvironment.shared.profileManagerRef.userProfile(for: address, tx: transaction)?.bioForDisplay {
let label = builder.addSubtitleLabel(text: bioText)
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
label.textAlignment = .center
}
let recipientAddress = contactThread.contactAddress
let identityManager = DependenciesBridge.shared.identityManager
let isVerified = identityManager.verificationState(for: recipientAddress, tx: transaction) == .verified
if isVerified {
let subtitle = NSMutableAttributedString()
subtitle.append(SignalSymbol.safetyNumber.attributedString(for: .subheadline, clamped: true))
subtitle.append(" ")
subtitle.append(SafetyNumberStrings.verified)
builder.addSubtitleLabel(attributedText: subtitle)
}
builder.addButtons()
return builder.build()
}
init(
delegate: ConversationHeaderDelegate,
sizeClass: ConversationAvatarView.Configuration.SizeClass,
options: Options,
isTerminatedGroup: Bool,
transaction: DBReadTransaction,
) {
self.delegate = delegate
self.sizeClass = sizeClass
self.options = options
self.transaction = transaction
addFirstSubviews(isTerminatedGroup: isTerminatedGroup, transaction: transaction)
}
mutating func addFirstSubviews(isTerminatedGroup: Bool, transaction: DBReadTransaction) {
let avatarView = buildAvatarView(transaction: transaction)
let avatarWrapper = UIView.container()
avatarWrapper.addSubview(avatarView)
avatarView.autoPinEdgesToSuperviewEdges()
subviews.append(avatarWrapper)
subviews.append(UIView.spacer(withHeight: 8))
subviews.append(buildThreadNameLabel())
if isTerminatedGroup {
subviews.append(buildGroupTerminatedBanner())
subviews.append(UIView.spacer(withHeight: 8))
}
}
mutating func addButtons() {
var buttons = [UIView]()
if options.contains(.message) {
buttons.append(buildIconButton(
icon: .settingsChats,
title: OWSLocalizedString(
"CONVERSATION_SETTINGS_MESSAGE_BUTTON",
comment: "Button to message the chat",
),
action: { [weak delegate] in
guard let delegate else { return }
SignalApp.shared.dismissAllModals(animated: true, completion: {
SignalApp.shared.presentConversationForThread(
threadUniqueId: delegate.thread.uniqueId,
action: .compose,
animated: true,
)
})
},
))
}
if ConversationViewController.canCall(threadViewModel: delegate.threadViewModel) {
let callService = AppEnvironment.shared.callService!
let currentCall = callService.callServiceState.currentCall
let hasCurrentCall = currentCall != nil
let isCurrentCallForThread = { () -> Bool in
switch currentCall?.mode {
case nil: return false
case .individual(let call): return call.thread.uniqueId == delegate.thread.uniqueId
case .groupThread(let call): return call.groupId.serialize() == (delegate.thread as? TSGroupThread)?.groupId
case .callLink: return false
}
}()
if options.contains(.videoCall) {
buttons.append(buildIconButton(
icon: .buttonVideoCall,
title: OWSLocalizedString(
"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: .buttonVoiceCall,
title: OWSLocalizedString(
"CONVERSATION_SETTINGS_VOICE_CALL_BUTTON",
comment: "Button to start a voice call",
),
isEnabled: isCurrentCallForThread || !hasCurrentCall,
action: { [weak delegate] in
delegate?.startCall(withVideo: false)
},
))
}
}
if options.contains(.mute) {
buttons.append(buildIconButton(
icon: .buttonMute,
title: delegate.threadViewModel.isMuted
? OWSLocalizedString(
"CONVERSATION_SETTINGS_MUTED_BUTTON",
comment: "Button to unmute the chat",
)
: OWSLocalizedString(
"CONVERSATION_SETTINGS_MUTE_BUTTON",
comment: "Button to mute the chat",
),
menu: ConversationSettingsViewController.muteUnmuteMenu(
for: delegate.threadViewModel,
actionExecuted: { [weak delegate] in
delegate?.updateTableContents(shouldReload: true)
},
),
))
}
if options.contains(.search), !delegate.isGroupV1Thread {
buttons.append(buildIconButton(
icon: .buttonSearch,
title: OWSLocalizedString(
"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 *= 2 }
buttons.forEach { $0.autoSetDimension(.width, toSize: buttonWidth) }
func addButtonRow(_ buttons: [UIView]) {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.alignment = .top
stackView.spacing = spacerWidth
buttons.forEach { stackView.addArrangedSubview($0) }
subviews.append(stackView)
}
subviews.append(.spacer(withHeight: 20))
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, title: String, isEnabled: Bool = true, action: @escaping () -> Void) -> UIView {
let button = SettingsHeaderButton(title: title.capitalized, icon: icon) { [weak delegate] in
delegate?.tappedButton()
action()
}
button.isEnabled = isEnabled
button.buttonBackgroundColor = delegate.tableViewController.cellBackgroundColor
button.selectedButtonBackgroundColor = delegate.tableViewController.cellSelectedBackgroundColor
if maxIconButtonWidth < button.minimumWidth {
maxIconButtonWidth = button.minimumWidth
}
return button
}
mutating func buildIconButton(icon: ThemeIcon, title: String, isEnabled: Bool = true, menu: UIMenu) -> UIView {
let button = SettingsHeaderButton(title: title.capitalized, icon: icon)
button.isEnabled = isEnabled
button.menu = menu
button.buttonBackgroundColor = delegate.tableViewController.cellBackgroundColor
button.selectedButtonBackgroundColor = delegate.tableViewController.cellSelectedBackgroundColor
if maxIconButtonWidth < button.minimumWidth {
maxIconButtonWidth = button.minimumWidth
}
return button
}
mutating func addGroupDescriptionPreview(text: String) {
let previewView: GroupDescriptionPreviewView
if
let groupThread = delegate.thread as? TSGroupThread,
delegate.canEditConversationAttributes
{
previewView = GroupDescriptionPreviewView(editableGroupThread: groupThread)
previewView.delegate = delegate.groupDescriptionDelegate
} else {
previewView = GroupDescriptionPreviewView()
}
previewView.descriptionText = text
previewView.groupName = delegate.threadName(
renderLocalUserAsNoteToSelf: true,
transaction: transaction,
)
previewView.font = .dynamicTypeSubheadlineClamped
previewView.textColor = Theme.secondaryTextAndIconColor
previewView.textAlignment = .center
previewView.numberOfLines = 2
subviews.append(previewView)
hasSubtitleLabel = true
}
mutating func addCreateGroupDescriptionButton() {
let button = OWSButton { [weak delegate] in delegate?.didTapAddGroupDescription() }
button.setTitle(OWSLocalizedString(
"GROUP_DESCRIPTION_PLACEHOLDER",
comment: "Placeholder text for 'group description' field.",
), for: .normal)
button.setTitleColor(Theme.secondaryTextAndIconColor, for: .normal)
button.titleLabel?.font = .dynamicTypeSubheadlineClamped
// For some reason, setting edge insets to 0 uses a default, non-zero inset
button.ows_contentEdgeInsets = .init(hMargin: 0, vMargin: .ulpOfOne)
subviews.append(button)
hasSubtitleLabel = true
}
func buildGroupTerminatedBanner() -> UIView {
let banner = UIView()
let textStackView = UIStackView()
textStackView.axis = .horizontal
textStackView.spacing = 6
banner.backgroundColor = UIColor.Signal.quaternaryFill
banner.layer.cornerRadius = 16
banner.layer.masksToBounds = true
let iconLabel = UILabel()
let textLabel = UILabel()
textLabel.font = .dynamicTypeSubheadlineClamped
textLabel.textColor = UIColor.Signal.label
textLabel.numberOfLines = 0
iconLabel.attributedText = SignalSymbol.groupXInline.attributedString(for: .subheadline, clamped: true)
textLabel.text = OWSLocalizedString(
"END_GROUP_BANNER_LABEL",
comment: "Label for a banner in group settings indicating that the group has been ended",
)
textStackView.addArrangedSubview(iconLabel)
textStackView.addArrangedSubview(textLabel)
banner.addSubview(textStackView)
textStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textStackView.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 12),
textStackView.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -12),
textStackView.topAnchor.constraint(equalTo: banner.topAnchor, constant: 6),
textStackView.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -6),
])
banner.isAccessibilityElement = true
banner.accessibilityLabel = textLabel.text
return banner
}
func buildAvatarView(transaction: DBReadTransaction) -> UIView {
let avatarView = ConversationAvatarView(
sizeClass: sizeClass,
localUserDisplayMode: options.contains(.renderLocalUserAsNoteToSelf) ? .noteToSelf : .asUser,
)
avatarView.update(transaction) {
$0.dataSource = .thread(delegate.thread)
$0.storyConfiguration = .autoUpdate()
}
avatarView.interactionDelegate = delegate
// Track the most recent avatar view.
delegate.avatarView = avatarView
return avatarView
}
func buildThreadNameLabel() -> UIButton {
var config = UIButton.Configuration.plain()
let title = delegate.threadAttributedString(
renderLocalUserAsNoteToSelf: options.contains(.renderLocalUserAsNoteToSelf),
tx: transaction,
).styled(with: .alignment(.center))
config.attributedTitle = AttributedString(title)
config.titleLineBreakMode = .byWordWrapping
config.baseForegroundColor = UIColor.Signal.label
let action: UIAction? = if delegate.canTapThreadName {
UIAction { [weak delegate] _ in
delegate?.didTapThreadName()
}
} else {
nil
}
return UIButton(configuration: config, primaryAction: action)
}
static func threadAttributedString(
threadName: String,
isNoteToSelf: Bool,
isSystemContact: Bool,
canTap: Bool,
tx: DBReadTransaction,
) -> NSAttributedString {
let font = UIFont.dynamicTypeFont(ofStandardSize: 26, weight: .semibold)
let attributedString = NSMutableAttributedString(string: threadName, attributes: [
.foregroundColor: UIColor.label,
.font: font,
])
if isNoteToSelf {
attributedString.append(" ")
let verifiedBadgeImage = Theme.iconImage(.official)
let verifiedBadgeAttachment = NSAttributedString.with(
image: verifiedBadgeImage,
font: .dynamicTypeTitle3,
centerVerticallyRelativeTo: font,
heightReference: .pointSize,
)
attributedString.append(verifiedBadgeAttachment)
}
if isSystemContact {
let contactIcon = SignalSymbol.personCircle.attributedString(
dynamicTypeBaseSize: 20,
weight: .bold,
leadingCharacter: .nonBreakingSpace,
)
attributedString.append(contactIcon)
}
if canTap {
let chevron = SignalSymbol.chevronTrailing(for: threadName).attributedString(
dynamicTypeBaseSize: 24,
weight: .bold,
leadingCharacter: .nonBreakingSpace,
attributes: [.foregroundColor: UIColor.Signal.secondaryLabel],
)
attributedString.append(chevron)
}
return attributedString
}
@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: 4))
let label = buildHeaderSubtitleLabel(attributedText: attributedText)
subviews.append(label)
hasSubtitleLabel = true
return label
}
mutating func addMemberLabel(label: String, color: UIColor) -> UILabel {
subviews.append(UIView.spacer(withHeight: 4))
let memberLabelLabel = CVCapsuleLabel(
attributedText: NSAttributedString(string: label),
textColor: color,
font: nil,
highlightRange: NSRange(location: 0, length: (label as NSString).length),
highlightFont: .dynamicTypeSubheadlineClamped,
axLabelPrefix: OWSLocalizedString(
"MEMBER_LABEL_AX_PREFIX",
comment: "Accessibility prefix for member labels.",
),
presentationContext: .nonMessageBubble,
numberOfLines: 1,
signalSymbolRange: nil,
onTap: { [weak delegate] in
delegate?.didTapMemberLabel()
},
)
subviews.append(memberLabelLabel)
hasSubtitleLabel = true
return memberLabelLabel
}
mutating func addLegacyGroupView() {
subviews.append(UIView.spacer(withHeight: 12))
let legacyGroupView = LegacyGroupView(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 overridden
label.textColor = Theme.secondaryTextAndIconColor
label.lineBreakMode = .byTruncatingTail
label.font = .dynamicTypeSubheadlineClamped
label.attributedText = attributedText
return label
}
func build() -> UIView {
let header = UIStackView(arrangedSubviews: subviews)
header.axis = .vertical
header.alignment = .center
header.layoutMargins = .init(top: 0, left: 0, bottom: 24, right: 0)
header.isLayoutMarginsRelativeArrangement = true
header.isUserInteractionEnabled = true
header.accessibilityIdentifier = UIView.accessibilityIdentifier(in: delegate, name: "mainSectionHeader")
if !options.contains(.noBackground) {
header.addBackgroundView(withBackgroundColor: delegate.tableViewController.tableBackgroundColor)
}
return header
}
}
// MARK: -
@MainActor
protocol ConversationHeaderDelegate: UIViewController, ConversationAvatarViewDelegate {
var tableViewController: OWSTableViewController2 { get }
var thread: TSThread { get }
var threadViewModel: ThreadViewModel { get }
func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: DBReadTransaction) -> String
var avatarView: ConversationAvatarView? { get set }
var isGroupV1Thread: Bool { get }
var canEditConversationAttributes: Bool { get }
var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { get }
func updateTableContents(shouldReload: Bool)
func tappedConversationSearch()
func startCall(withVideo: Bool)
func tappedButton()
func didTapUnblockThread(completion: @escaping () -> Void)
func didTapAddGroupDescription()
var canTapThreadName: Bool { get }
func didTapThreadName()
func didTapMemberLabel()
}
// MARK: -
extension ConversationHeaderDelegate {
func threadName(renderLocalUserAsNoteToSelf: Bool, transaction: DBReadTransaction) -> String {
var threadName: String
if thread.isNoteToSelf, !renderLocalUserAsNoteToSelf {
let profileManager = SSKEnvironment.shared.profileManagerRef
threadName = profileManager.localUserProfile(tx: transaction)?.filteredFullName ?? ""
} else {
threadName = SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: transaction)
}
if let contactThread = thread as? TSContactThread {
if let phoneNumber = contactThread.contactAddress.phoneNumber, phoneNumber == threadName {
threadName = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber)
}
}
return threadName
}
func threadAttributedString(renderLocalUserAsNoteToSelf: Bool, tx: DBReadTransaction) -> NSAttributedString {
let threadName = threadName(renderLocalUserAsNoteToSelf: renderLocalUserAsNoteToSelf, transaction: tx)
let isSystemContact =
if let contactThread = self.thread as? TSContactThread {
SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(
for: contactThread.contactAddress,
transaction: tx,
) != nil
} else {
false
}
return ConversationHeaderBuilder.threadAttributedString(
threadName: threadName,
isNoteToSelf: thread.isNoteToSelf,
isSystemContact: isSystemContact,
canTap: self.canTapThreadName,
tx: tx,
)
}
func startCall(withVideo: Bool) {
guard ConversationViewController.canCall(threadViewModel: threadViewModel) else {
owsFailDebug("Tried to start a call when calls are disabled")
return
}
let callTarget: CallTarget
if let contactThread = thread as? TSContactThread {
callTarget = .individual(contactThread)
} else if let groupThread = thread as? TSGroupThread {
if withVideo {
if let groupId = try? groupThread.groupIdentifier {
callTarget = .groupThread(groupId)
} else {
owsFailDebug("Tried to start a group call with an invalid groupId")
return
}
} else {
owsFailDebug("Tried to start an audio only group call")
return
}
} else {
owsFailDebug("Tried to start an invalid call")
return
}
guard !threadViewModel.isBlocked else {
didTapUnblockThread { [weak self] in
self?.startCall(withVideo: withVideo)
}
return
}
let callService = AppEnvironment.shared.callService!
if let currentCall = callService.callServiceState.currentCall {
if currentCall.mode.matches(callTarget) {
AppEnvironment.shared.windowManagerRef.returnToCallView()
} else {
owsFailDebug("Tried to start call while call was ongoing")
}
return
}
// We initiated a call, so if there was a pending message request we should accept it.
ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread)
callService.initiateCall(to: callTarget, isVideo: withVideo)
}
}
extension ConversationSettingsViewController: ConversationHeaderDelegate {
var tableViewController: OWSTableViewController2 { self }
func buildMainHeader() -> UIView {
let options: ConversationHeaderBuilder.Options
if isTerminatedGroup {
options = [.mute, .search]
} else if callRecords.isEmpty {
options = [.videoCall, .audioCall, .mute, .search, .renderLocalUserAsNoteToSelf]
} else {
// Call details
options = [.message, .videoCall, .audioCall, .mute]
}
return ConversationHeaderBuilder.buildHeader(
for: thread,
sizeClass: .eightyEight,
options: options,
memberLabel: nil,
delegate: self,
)
}
var groupDescriptionDelegate: GroupDescriptionViewControllerDelegate? { self }
func tappedButton() {}
func didTapAddGroupDescription() {
guard let groupThread = thread as? TSGroupThread else { return }
let vc = GroupDescriptionViewController(
groupModel: groupThread.groupModel,
options: [.editImmediately, .updateImmediately],
)
vc.descriptionDelegate = self
presentFormSheet(OWSNavigationController(rootViewController: vc), animated: true)
}
var canTapThreadName: Bool {
!thread.isGroupThread && !thread.isNoteToSelf
}
func didTapThreadName() {
guard let contactThread = self.thread as? TSContactThread else {
owsFailDebug("Conversation name should only be tappable for contact threads")
return
}
ContactAboutSheet(thread: contactThread, spoilerState: self.spoilerState)
.present(from: self)
}
func didTapMemberLabel() {}
}
extension ConversationSettingsViewController: GroupDescriptionViewControllerDelegate {
func groupDescriptionViewControllerDidComplete(groupDescription: String?) {
reloadThreadAndUpdateContent()
}
}
// MARK: -
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 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 else {
owsFailDebug("Missing longPressBlock.")
return
}
longPressBlock()
}
}