Signal-iOS/Signal/ConversationView/ConversationViewController+Banners.swift
Adam Sharp b42710cf90 Work around use of deprecated UIButton API
The old UIButton API is still functional as long as we don't use
UIButton.Configuration, so we can safely ignore these warnings until we're
ready to adopt the configuration API across the codebase.
2024-07-03 14:27:48 -04:00

731 lines
28 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
public extension ConversationViewController {
static func createBanner(title: String,
bannerColor: UIColor,
tapBlock: @escaping () -> Void) -> UIView {
owsAssertDebug(!title.isEmpty)
let bannerView = GestureView()
bannerView.addTap(block: tapBlock)
bannerView.backgroundColor = bannerColor
bannerView.accessibilityIdentifier = "banner_close"
let label = buildBannerLabel(title: title)
label.textAlignment = .center
let closeIcon = UIImage(imageLiteralResourceName: "x-extra-small")
let closeButton = UIImageView(image: closeIcon)
closeButton.tintColor = .white
bannerView.addSubview(closeButton)
let kBannerCloseButtonPadding: CGFloat = 8
closeButton.autoPinEdge(toSuperviewEdge: .top, withInset: kBannerCloseButtonPadding)
closeButton.autoPinTrailingToSuperviewMargin(withInset: kBannerCloseButtonPadding)
closeButton.autoSetDimensions(to: closeIcon.size)
bannerView.addSubview(label)
label.autoPinEdge(toSuperviewEdge: .top, withInset: 5)
label.autoPinEdge(toSuperviewEdge: .bottom, withInset: 5)
let kBannerHPadding: CGFloat = 15
label.autoPinLeadingToSuperviewMargin(withInset: kBannerHPadding)
let kBannerHSpacing: CGFloat = 10
closeButton.autoPinTrailing(toEdgeOf: label, offset: kBannerHSpacing)
return bannerView
}
// MARK: - Pending Join Requests Banner
func createPendingJoinRequestBanner(
viewState: CVViewState,
pendingMemberRequests: Set<SignalServiceAddress>,
viewMemberRequestsBlock: @escaping () -> Void
) -> UIView {
owsAssertDebug(!pendingMemberRequests.isEmpty)
let format = OWSLocalizedString("PENDING_GROUP_MEMBERS_REQUEST_BANNER_%d", tableName: "PluralAware",
comment: "Format for banner indicating that there are pending member requests to join the group. Embeds {{ the number of pending member requests }}.")
let title = String.localizedStringWithFormat(format, pendingMemberRequests.count)
let dismissButton = OWSButton(title: CommonStrings.dismissButton) { [weak self] in
guard let self = self else { return }
AssertIsOnMainThread()
self.databaseStorage.write { transaction in
viewState.hidePendingMemberRequestsBanner(
currentPendingMembers: pendingMemberRequests,
transaction: transaction
)
}
self.ensureBannerState()
}
dismissButton.titleLabel?.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
let viewRequestsLabel = OWSLocalizedString("PENDING_GROUP_MEMBERS_REQUEST_BANNER_VIEW_REQUESTS",
comment: "Label for the 'view requests' button in the pending member requests banner.")
let viewRequestsButton = OWSButton(title: viewRequestsLabel, block: viewMemberRequestsBlock)
viewRequestsButton.titleLabel?.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
return Self.createBanner(title: title,
buttons: [dismissButton, viewRequestsButton],
accessibilityIdentifier: "pending_group_request_banner")
}
// MARK: - Name collision banners
func fetchAvatar(
for address: SignalServiceAddress,
tx: SDSAnyReadTransaction
) -> UIImage? {
if
address.isLocalAddress,
let profileAvatar = self.profileManager.localProfileAvatarImage()
{
return profileAvatar.resizedImage(to: CGSize(square: 24))
}
return Self.avatarBuilder.avatarImage(
forAddress: address,
diameterPoints: 24,
localUserDisplayMode: .asUser,
transaction: tx
)
}
func createMessageRequestNameCollisionBannerIfNecessary(viewState: CVViewState) -> UIView? {
guard let contactThread = thread as? TSContactThread else { return nil }
let collisionFinder = ContactThreadNameCollisionFinder
.makeToCheckMessageRequestNameCollisions(forContactThread: contactThread)
guard let (avatar1, avatar2) = databaseStorage.read(block: { tx -> (UIImage?, UIImage?)? in
guard
viewState.shouldShowMessageRequestNameCollisionBanner(transaction: tx),
let collision = collisionFinder.findCollisions(transaction: tx).first
else { return nil }
return (
fetchAvatar(for: collision.elements[0].address, tx: tx),
fetchAvatar(for: collision.elements[1].address, tx: tx)
)
}) else { return nil }
let banner = NameCollisionBanner()
banner.labelText = OWSLocalizedString(
"MESSAGE_REQUEST_NAME_COLLISION_BANNER_LABEL",
comment: "Banner label notifying user that a new message is from a user with the same name as an existing contact")
banner.reviewActionText = CommonStrings.viewButton
if let avatar1 = avatar1, let avatar2 = avatar2 {
banner.primaryImage = avatar1
banner.secondaryImage = avatar2
}
banner.closeAction = { [weak self] in
guard let self = self else { return }
self.databaseStorage.write { viewState.hideMessageRequestNameCollisionBanner(transaction: $0) }
self.ensureBannerState()
}
banner.reviewAction = { [weak self] in
guard let self = self else { return }
let vc = NameCollisionResolutionViewController(collisionFinder: collisionFinder, collisionDelegate: self)
vc.present(fromViewController: self)
}
return banner
}
func createGroupMembershipCollisionBannerIfNecessary() -> UIView? {
guard let groupThread = thread as? TSGroupThread else { return nil }
// Collision discovery can be expensive, so we only build our banner if
// we've already done the expensive bit
guard let collisionFinder = viewState.groupNameCollisionFinder else {
let collisionFinder = GroupMembershipNameCollisionFinder(thread: groupThread)
viewState.groupNameCollisionFinder = collisionFinder
firstly(on: DispatchQueue.sharedUserInitiated) {
self.databaseStorage.read { readTx in
// Prewarm our collision finder off the main thread
_ = collisionFinder.findCollisions(transaction: readTx)
}
}.done(on: DispatchQueue.main) {
self.ensureBannerState()
}.catch { error in
owsFailDebug("\(error)")
}
return nil
}
guard collisionFinder.hasFetchedProfileUpdateMessages else {
// We already have a collision finder. It just hasn't finished fetching.
return nil
}
// Fetch the necessary info to build the banner
guard let (title, avatar1, avatar2) = databaseStorage.read(block: { readTx -> (String, UIImage?, UIImage?)? in
let collisionSets = collisionFinder.findCollisions(transaction: readTx)
guard !collisionSets.isEmpty else { return nil }
let totalCollisionElementCount = collisionSets.reduce(0) { $0 + $1.elements.count }
let title: String
if collisionSets.count > 1 {
let titleFormat = OWSLocalizedString(
"GROUP_MEMBERSHIP_MULTIPLE_COLLISIONS_BANNER_TITLE_%d",
tableName: "PluralAware",
comment: "Banner title alerting user to a name collision set ub the group membership. Embeds {{ total number of colliding members }}"
)
title = String.localizedStringWithFormat(titleFormat, collisionSets.count)
} else {
let titleFormat = OWSLocalizedString(
"GROUP_MEMBERSHIP_COLLISIONS_BANNER_TITLE_%d",
tableName: "PluralAware",
comment: "Banner title alerting user about multiple name collisions in group membership. Embeds {{ number of sets of colliding members }}"
)
title = String.localizedStringWithFormat(titleFormat, totalCollisionElementCount)
}
let avatar1 = fetchAvatar(
for: collisionSets[0].elements[0].address,
tx: readTx
)
let avatar2 = fetchAvatar(
for: collisionSets[0].elements[1].address,
tx: readTx
)
return (title, avatar1, avatar2)
}) else { return nil }
let banner = NameCollisionBanner()
banner.labelText = title
banner.reviewActionText = CommonStrings.viewButton
if let avatar1 = avatar1, let avatar2 = avatar2 {
banner.primaryImage = avatar1
banner.secondaryImage = avatar2
}
banner.closeAction = { [weak self] in
self?.databaseStorage.asyncWrite(block: { writeTx in
collisionFinder.markCollisionsAsResolved(transaction: writeTx)
}, completion: {
self?.ensureBannerState()
})
}
banner.reviewAction = { [weak self] in
guard let self = self else { return }
let vc = NameCollisionResolutionViewController(collisionFinder: collisionFinder, collisionDelegate: self)
vc.present(fromViewController: self)
}
return banner
}
}
// MARK: -
fileprivate extension ConversationViewController {
static func buildBannerLabel(title: String) -> UILabel {
let label = UILabel()
label.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
label.text = title
label.textColor = .white
label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping
return label
}
static func createBanner(title: String,
buttons: [UIView],
accessibilityIdentifier: String) -> UIView {
let titleLabel = buildBannerLabel(title: title)
titleLabel.font = .dynamicTypeSubheadlineClamped
let buttonRow = UIStackView(arrangedSubviews: [UIView.hStretchingSpacer()] + buttons)
buttonRow.axis = .horizontal
buttonRow.spacing = 24
let bannerView = UIStackView(arrangedSubviews: [ titleLabel, buttonRow ])
bannerView.axis = .vertical
bannerView.alignment = .fill
bannerView.spacing = 10
bannerView.layoutMargins = UIEdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)
bannerView.isLayoutMarginsRelativeArrangement = true
bannerView.addBackgroundView(withBackgroundColor: .ows_accentBlue)
bannerView.accessibilityIdentifier = accessibilityIdentifier
return bannerView
}
}
// MARK: -
// A convenience view that allows block-based gesture handling.
public class GestureView: UIView {
public init() {
super.init(frame: .zero)
self.layoutMargins = .zero
}
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
public typealias BlockType = () -> Void
private var tapBlock: BlockType?
public func addTap(block tapBlock: @escaping () -> Void) {
owsAssertDebug(self.tapBlock == nil)
self.tapBlock = tapBlock
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap(_:))))
}
// MARK: - Events
@objc
func didTap(_ sender: UITapGestureRecognizer) {
guard sender.state == .recognized else {
return
}
guard let tapBlock = tapBlock else {
owsFailDebug("Missing tapBlock.")
return
}
tapBlock()
}
}
private class NameCollisionBanner: UIView {
private enum Constants {
static let avatarSize = CGSize(square: 24)
static let avatarBorderSize: CGFloat = 2
}
var primaryImage: UIImage? {
get { primaryImageView.image }
set {
primaryImageView.image = newValue
setNeedsUpdateConstraints()
}
}
var secondaryImage: UIImage? {
get { secondaryImageView.image }
set {
secondaryImageView.image = newValue
setNeedsUpdateConstraints()
}
}
var labelText: String? {
get { label.text }
set { label.text = newValue }
}
var reviewActionText: String? {
get { reviewButton.title(for: .normal) }
set { reviewButton.setTitle(newValue, for: .normal) }
}
var reviewAction: () -> Void {
get { reviewButton.block }
set { reviewButton.block = newValue }
}
var closeAction: () -> Void {
get { closeButton.block }
set { closeButton.block = newValue }
}
private let label: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.font = UIFont.dynamicTypeFootnoteClamped
label.textColor = Theme.secondaryTextAndIconColor
return label
}()
private let primaryImageView: UIImageView = {
let avatarSize = Constants.avatarSize
let imageView = UIImageView.withTemplateImageName("info", tintColor: Theme.secondaryTextAndIconColor)
imageView.layer.cornerRadius = avatarSize.smallerAxis / 2
imageView.layer.masksToBounds = true
imageView.autoSetDimensions(to: avatarSize)
imageView.setCompressionResistanceHigh()
imageView.setContentHuggingHigh()
return imageView
}()
private class SecondaryImageView: UIImageView {
override func layoutSubviews() {
layer.cornerRadius = Constants.avatarSize.smallerAxis / 2
layer.masksToBounds = true
// Mask out a border around the primary avatar.
// The background is a UIVisualEffect, so we can't just rely
// on adding a border around the primary avatar itself.
let borderSize = Constants.avatarBorderSize
let maskPath = UIBezierPath(rect: bounds)
let circlePath = UIBezierPath(
ovalIn: .init(
origin: .init(
x: self.width / 2 - borderSize,
y: self.height / 2 - borderSize
),
size: self.frame.size.plus(.square(borderSize * 2))
)
)
maskPath.append(circlePath)
let maskLayer = CAShapeLayer()
maskLayer.path = maskPath.cgPath
// Mask the inverse of the circle path
maskLayer.fillRule = .evenOdd
self.layer.mask = maskLayer
}
}
private let secondaryImageView: SecondaryImageView = {
let imageView = SecondaryImageView()
imageView.autoSetDimensions(to: Constants.avatarSize)
imageView.setCompressionResistanceHigh()
imageView.setContentHuggingHigh()
return imageView
}()
private let closeButton: OWSButton = {
let button = OWSButton(
imageName: "x-20",
tintColor: Theme.secondaryTextAndIconColor
)
button.accessibilityLabel = OWSLocalizedString(
"BANNER_CLOSE_ACCESSIBILITY_LABEL",
comment: "Accessibility label for banner close button"
)
button.translatesAutoresizingMaskIntoConstraints = false
button.setCompressionResistanceHigh()
button.setContentHuggingHigh()
return button
}()
private let reviewButton: OWSButton = {
let button = OWSRoundedButton()
button.backgroundColor = Theme.isDarkThemeEnabled ? UIColor(white: 1, alpha: 0.16) : UIColor(white: 0, alpha: 0.08)
button.setTitleColor(Theme.primaryTextColor, for: .normal)
button.titleLabel?.font = UIFont.dynamicTypeFootnoteClamped.semibold()
button.dimsWhenHighlighted = true
button.translatesAutoresizingMaskIntoConstraints = false
button.ows_contentEdgeInsets = .init(hMargin: 12, vMargin: 6)
button.setCompressionResistanceHigh()
button.setContentHuggingLow()
return button
}()
override init(frame: CGRect) {
super.init(frame: frame)
let stackView = UIStackView()
if UIAccessibility.isReduceTransparencyEnabled {
stackView.backgroundColor = Theme.secondaryBackgroundColor
} else {
let blurEffectView = UIVisualEffectView(effect: Theme.barBlurEffect)
stackView.addSubview(blurEffectView)
blurEffectView.autoPinEdgesToSuperviewEdges()
stackView.clipsToBounds = true
}
stackView.spacing = 12
stackView.layoutMargins = .init(margin: 12)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.axis = .horizontal
stackView.alignment = .center
let imageContainer = UIView.container()
imageContainer.addSubview(secondaryImageView)
imageContainer.addSubview(primaryImageView)
stackView.addArrangedSubview(imageContainer)
// Offsets adjusted in updateConstraints() based on content
primaryImageViewConstraints = (
top: primaryImageView.autoPinEdge(.top, to: .top, of: imageContainer, withOffset: 0),
leading: primaryImageView.autoPinEdge(.leading, to: .leading, of: imageContainer, withOffset: 4)
)
primaryImageView.autoPinEdge(toSuperviewEdge: .trailing)
primaryImageView.autoPinEdge(.bottom, to: .bottom, of: imageContainer)
// Secondary image is always offset to the top left of the primary image
secondaryImageView.autoPinEdge(.top, to: .top, of: primaryImageView, withOffset: -12)
secondaryImageView.autoPinEdge(.leading, to: .leading, of: primaryImageView, withOffset: -12)
stackView.addArrangedSubviews([label, reviewButton, closeButton])
accessibilityElements = [label, reviewButton, closeButton]
let cornerRadius: CGFloat = 18
let stackContainer = UIView.container()
stackContainer.addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
stackView.addBorder(with: .ows_blackAlpha10)
// Clip the stack view so the blurred background is clipped
stackView.layer.cornerRadius = cornerRadius
stackView.clipsToBounds = true
// Apply the shadow to the container so it doesn't get clipped out
stackContainer.layer.cornerRadius = cornerRadius
stackContainer.setShadow(radius: 8, opacity: 0.2, offset: .init(width: 0, height: 4))
let containerView = UIView()
containerView.layoutMargins = .init(hMargin: 16, vMargin: 8)
containerView.addSubview(stackContainer)
stackContainer.autoPinEdgesToSuperviewMargins()
self.addSubview(containerView)
containerView.autoPinEdgesToSuperviewEdges()
}
var primaryImageViewConstraints: (top: NSLayoutConstraint, leading: NSLayoutConstraint)?
override func updateConstraints() {
super.updateConstraints()
guard let constraints = primaryImageViewConstraints else { return }
// If we have a secondary image, we want to adjust our constraints a bit
let hasSecondaryImage = (secondaryImage != nil)
constraints.top.constant = hasSecondaryImage ? 12 : 0
constraints.leading.constant = hasSecondaryImage ? 16 : 4
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: -
extension ConversationViewController {
public func ensureBannerState() {
AssertIsOnMainThread()
// This method should be called rarely, so it's simplest to discard and
// rebuild the indicator view every time.
bannerView?.removeFromSuperview()
self.bannerView = nil
var banners = [UIView]()
// Most of these banners should hide themselves when the user scrolls
if !userHasScrolled {
let message: String?
let noLongerVerifiedIdentityKeys = databaseStorage.read { tx in self.noLongerVerifiedIdentityKeys(tx: tx) }
switch noLongerVerifiedIdentityKeys.count {
case 0:
message = nil
case 1:
let address = noLongerVerifiedIdentityKeys.first!.key
let displayName = databaseStorage.read { tx in contactsManager.displayName(for: address, tx: tx).resolvedValue() }
let format = (isGroupConversation
? OWSLocalizedString("MESSAGES_VIEW_1_MEMBER_NO_LONGER_VERIFIED_FORMAT",
comment: "Indicates that one member of this group conversation is no longer verified. Embeds {{user's name or phone number}}.")
: OWSLocalizedString("MESSAGES_VIEW_CONTACT_NO_LONGER_VERIFIED_FORMAT",
comment: "Indicates that this 1:1 conversation is no longer verified. Embeds {{user's name or phone number}}."))
message = String(format: format, displayName)
default:
message = OWSLocalizedString("MESSAGES_VIEW_N_MEMBERS_NO_LONGER_VERIFIED",
comment: "Indicates that more than one member of this group conversation is no longer verified.")
}
if let message {
let banner = ConversationViewController.createBanner(
title: message,
bannerColor: .ows_accentRed,
tapBlock: { [weak self] in
self?.noLongerVerifiedBannerViewWasTapped(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
}
)
banners.append(banner)
}
func buildBlockStateMessage() -> String? {
guard isGroupConversation else {
return nil
}
let blockedGroupMemberCount = self.blockedGroupMemberCount
if blockedGroupMemberCount > 0 {
return String.localizedStringWithFormat(
OWSLocalizedString("MESSAGES_VIEW_GROUP_N_MEMBERS_BLOCKED_%d", tableName: "PluralAware",
comment: "Indicates that some members of this group has been blocked. Embeds {{the number of blocked users in this group}}."),
blockedGroupMemberCount)
} else {
return nil
}
}
if let blockStateMessage = buildBlockStateMessage() {
let banner = ConversationViewController.createBanner(title: blockStateMessage,
bannerColor: .ows_accentRed) { [weak self] in
self?.blockBannerViewWasTapped()
}
banners.append(banner)
}
let pendingMemberRequests = self.pendingMemberRequests
if
!pendingMemberRequests.isEmpty,
self.canApprovePendingMemberRequests,
databaseStorage.read(block: { transaction in
// We will skip this read if the above checks fail, which
// will be most of the time.
viewState.shouldShowPendingMemberRequestsBanner(
currentPendingMembers: pendingMemberRequests,
transaction: transaction
)
}) {
let banner = self.createPendingJoinRequestBanner(
viewState: viewState,
pendingMemberRequests: pendingMemberRequests
) { [weak self] in
self?.showConversationSettingsAndShowMemberRequests()
}
banners.append(banner)
}
}
if let banner = createMessageRequestNameCollisionBannerIfNecessary(viewState: viewState) {
banners.append(banner)
}
if let banner = createGroupMembershipCollisionBannerIfNecessary() {
banners.append(banner)
}
if banners.isEmpty {
if hasViewDidAppearEverBegun {
updateContentInsets()
}
return
}
let bannerView = UIStackView(arrangedSubviews: banners)
bannerView.axis = .vertical
bannerView.alignment = .fill
self.view.addSubview(bannerView)
bannerView.autoPin(toTopLayoutGuideOf: self, withInset: 0)
bannerView.autoPinEdge(toSuperviewEdge: .leading)
bannerView.autoPinEdge(toSuperviewEdge: .trailing)
self.view.layoutSubviews()
self.bannerView = bannerView
if hasViewDidAppearEverBegun {
updateContentInsets()
}
}
private var pendingMemberRequests: Set<SignalServiceAddress> {
if let groupThread = thread as? TSGroupThread {
return groupThread.groupMembership.requestingMembers
} else {
return []
}
}
private var canApprovePendingMemberRequests: Bool {
if let groupThread = thread as? TSGroupThread {
return groupThread.isLocalUserFullMemberAndAdministrator
} else {
return false
}
}
private func blockBannerViewWasTapped() {
AssertIsOnMainThread()
if isBlockedConversation() {
// If this a blocked conversation, offer to unblock.
showUnblockConversationUI(completion: nil)
} else if isGroupConversation {
// If this a group conversation with at least one blocked member,
// Show the block list view.
let blockedGroupMemberCount = self.blockedGroupMemberCount
if blockedGroupMemberCount > 0 {
let vc = BlockListViewController()
navigationController?.pushViewController(vc, animated: true)
}
}
}
private func noLongerVerifiedBannerViewWasTapped(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
AssertIsOnMainThread()
let title: String
switch noLongerVerifiedIdentityKeys.count {
case 0:
return
case 1:
title = OWSLocalizedString(
"VERIFY_PRIVACY",
comment: "Label for button or row which allows users to verify the safety number of another user."
)
default:
title = OWSLocalizedString(
"VERIFY_PRIVACY_MULTIPLE",
comment: "Label for button or row which allows users to verify the safety numbers of multiple users."
)
}
let actionSheet = ActionSheetController()
actionSheet.addAction(ActionSheetAction(title: title, style: .default) { [weak self] _ in
self?.showNoLongerVerifiedUI(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
})
actionSheet.addAction(ActionSheetAction(title: CommonStrings.dismissButton,
accessibilityIdentifier: "dismiss",
style: .cancel) { [weak self] _ in
self?.resetVerificationStateToDefault(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
})
dismissKeyBoard()
presentActionSheet(actionSheet)
}
private var blockedGroupMemberCount: Int {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return 0
}
let blockedMembers = databaseStorage.read { readTx in
groupThread.groupModel.groupMembers.filter {
blockingManager.isAddressBlocked($0, transaction: readTx)
}
}
return blockedMembers.count
}
}