TM-SGNL-iOS/Signal/ConversationView/ConversationViewController+Banners.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

732 lines
28 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public 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()
SSKEnvironment.shared.databaseStorageRef.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 = SSKEnvironment.shared.profileManagerRef.localProfileAvatarImage
{
return profileAvatar.resizedImage(to: CGSize(square: 24))
}
return SSKEnvironment.shared.avatarBuilderRef.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) = SSKEnvironment.shared.databaseStorageRef.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 }
SSKEnvironment.shared.databaseStorageRef.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) {
SSKEnvironment.shared.databaseStorageRef.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) = SSKEnvironment.shared.databaseStorageRef.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
if self != nil {
SSKEnvironment.shared.databaseStorageRef.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 = SSKEnvironment.shared.databaseStorageRef.read { tx in self.noLongerVerifiedIdentityKeys(tx: tx) }
switch noLongerVerifiedIdentityKeys.count {
case 0:
message = nil
case 1:
let address = noLongerVerifiedIdentityKeys.first!.key
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.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,
SSKEnvironment.shared.databaseStorageRef.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 = SSKEnvironment.shared.databaseStorageRef.read { readTx in
groupThread.groupModel.groupMembers.filter {
SSKEnvironment.shared.blockingManagerRef.isAddressBlocked($0, transaction: readTx)
}
}
return blockedMembers.count
}
}