Path: blob/main/Signal/ConversationView/ConversationViewController+Banners.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalServiceKit
import SignalUI
// MARK: - Banner Management
extension ConversationViewController {
/// Checks if there are any banners that need to be displayed and shows them as necessary.
public func ensureBannerState() {
AssertIsOnMainThread()
guard !isInPreviewPlatter else {
return
}
var banners = [UIView]()
// Logic for whether or not should a certain banner be displayed is inside of each banner creation method.
// If the banner should not be shown its "create..." method would return `nil`.
// Most of these banners should hide themselves when the user scrolls
if !userHasScrolled {
// No Longer Verified
if let banner = createNoLongerVerifiedStateBanner() {
banners.append(banner)
}
}
// Pending Member requests
if let banner = createPendingJoinRequestBanner(viewState: viewState) {
banners.append(banner)
}
// Name Collision Banners
if let banner = createMessageRequestNameCollisionBanner(viewState: viewState) {
banners.append(banner)
}
if let banner = createGroupMembershipCollisionBanner() {
banners.append(banner)
}
// Pinned Messages
var newPinnedMessageBanner: ConversationBannerView?
if let banner = createPinnedMessageBannerIfNecessary() {
banners.append(banner)
newPinnedMessageBanner = banner
}
var oldPinnedMessageBanner: ConversationBannerView?
if let bannerStackView {
oldPinnedMessageBanner = pinnedMessageBanner(stackView: bannerStackView)
}
if let oldPinnedMessageBanner, let bannerStackView {
if let newPinnedMessageBanner {
// We used to have pinned messages, and still have some.
// We might need to set the configuration to trigger the animation.
if shouldAnimateNewPinnedMessage(oldPinnedMessageBanner: oldPinnedMessageBanner) {
if let newConfig = newPinnedMessageBanner.contentView.configuration as? ConversationBannerView.ContentConfiguration {
oldPinnedMessageBanner.contentView.configuration = newConfig
return
}
}
} else {
// We used to have pinned messages, and now have none. Fade out the PM banner.
oldPinnedMessageBanner.animateBannerFadeOut(completion: { _ in
bannerStackView.removeFromSuperview()
self.bannerStackView = nil
})
return
}
}
// This method should be called rarely, so it's simplest to discard and
// rebuild the indicator view every time.
if let bannerStackView {
bannerStackView.removeFromSuperview()
self.bannerStackView = nil
}
guard !banners.isEmpty else {
if hasViewDidAppearEverBegun {
updateContentInsets()
}
return
}
let topMargin: CGFloat = if #available(iOS 26, *) { 0 } else { 8 }
let hMargin = OWSTableViewController2.cellHInnerMargin
let bannersView = UIStackView(arrangedSubviews: banners)
bannersView.axis = .vertical
bannersView.alignment = .fill
bannersView.spacing = 8
bannersView.isLayoutMarginsRelativeArrangement = true
bannersView.directionalLayoutMargins = .init(top: topMargin, leading: hMargin, bottom: 0, trailing: hMargin)
bannersView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bannersView)
NSLayoutConstraint.activate([
bannersView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
bannersView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
bannersView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
if oldPinnedMessageBanner == nil, newPinnedMessageBanner != nil {
if let newPinnedMessageBanner = pinnedMessageBanner(stackView: bannersView) {
newPinnedMessageBanner.animateBannerFadeIn()
}
}
bannerStackView = bannersView
if hasViewDidAppearEverBegun {
updateContentInsets()
}
}
private func shouldAnimateNewPinnedMessage(oldPinnedMessageBanner: ConversationBannerView) -> Bool {
guard
let oldConfig = oldPinnedMessageBanner.contentView.configuration as? ConversationBannerView.ContentConfiguration
else {
return false
}
return threadViewModel.pinnedMessages.count > 1 && threadViewModel.pinnedMessages.count > oldConfig.totalPinnedMessageCount
}
/// Returns the pinned message banner if it exists. There should only ever be one.
private func pinnedMessageBanner(stackView: UIStackView) -> ConversationBannerView? {
return stackView.arrangedSubviews.first { view in
guard
let banner = view as? ConversationBannerView,
let config = banner.contentView.configuration as? ConversationBannerView.ContentConfiguration
else {
return false
}
return config.totalPinnedMessageCount > 0
} as? ConversationBannerView
}
}
// MARK: - Single class for all banners
private class ConversationBannerView: UIView {
var contentView: UIView & UIContentView
var blurBackgroundView: UIVisualEffectView?
static func fadeInAnimator() -> UIViewPropertyAnimator {
return UIViewPropertyAnimator(
duration: 0.35,
springDamping: 1,
springResponse: 0.35,
)
}
var pinnedMessageDelegate: PinnedMessageInteractionManagerDelegate? {
get {
guard let _contentView = contentView as? ConversationBannerContentView else {
return nil
}
return _contentView.pinnedMessageInteractionDelegate
}
set {
guard let _contentView = contentView as? ConversationBannerContentView else {
return
}
_contentView.pinnedMessageInteractionDelegate = newValue
}
}
/// Contains all the information necessary to construct any banner.
struct ContentConfiguration: UIContentConfiguration {
/// Text displayed on the top row of the banner.
let title: String?
/// Text displayed in the banner, under the title if it exists
let body: NSAttributedString
/// Thumbnail to show with pinned messages
let thumbnail: UIImageView?
/// Title for the button displayed at the trailing edge of the banner, typically something like "View".
/// Both `viewButtonTitle` and `viewButtonAction` must be set in orded for "View" button to be displayed.
let viewButtonTitle: String?
/// Action to perform when user taps on "View" button.
/// Both `viewButtonTitle` and `viewButtonAction` must be set in orded for "View" button to be displayed.
let viewButtonAction: UIAction?
/// Action to perform when user taps on Close (X) button.
/// Close button will not be displayed if this is `nil`.
let dismissButtonAction: UIAction?
/// Small view displayed at the leading edge of the banner.
let leadingAccessoryView: UIView?
/// Action to perform when the entire banner is tapped.
/// Banner will not be tappable if this is nil.
var bannerTapAction: (() -> Void)?
/// Total count of pinned messages represented by the banner.
/// Should be 0 if this is not a pinned messages banner.
let totalPinnedMessageCount: Int
/// Whether the banner is in a group that has been terminated.
let isTerminatedGroup: Bool
func makeContentView() -> any UIView & UIContentView {
return ConversationBannerContentView(configuration: self)
}
func updated(for state: any UIConfigurationState) -> ConversationBannerView.ContentConfiguration {
return self
}
}
private class ConversationBannerContentView: UIStackView, UIContentView {
private enum PinnedMessageConstants {
static let bannerHeight = 50.0
static let thumbnailSize = 30.0
static let spacing = 8.0
static let leadingPadding = 16.0
// Image is 30 px, total banner is 50, which leaves 10 on top&bottom
static let thumbnailPadding = 10.0
// The leading scroll accessory adds 10 px to the total size when present.
static let leadingAccessoryPadding = 10.0
// Buffer so the text doesn't overlap with the trailing pin button.
static let pinButtonTrailingPadding = 48.0
}
weak var pinnedMessageInteractionDelegate: PinnedMessageInteractionManagerDelegate?
var configuration: any UIContentConfiguration {
get { _configuration }
set {
guard let configuration = newValue as? ContentConfiguration else { return }
_configuration = configuration
rebuildContent()
}
}
private var _configuration: ContentConfiguration
// Required stored labels for banner animations.
var textStackView = UIStackView()
var titleLabel = UILabel()
var bodyLabel = UILabel()
var thumbnail: UIImageView?
var isAnimating: Bool = false
init(configuration: ContentConfiguration) {
_configuration = configuration
super.init(frame: .zero)
axis = .horizontal
isLayoutMarginsRelativeArrangement = true
spacing = 12
rebuildContent()
}
private func buildTitleLabel(text: String) -> UILabel {
let _titleLabel = UILabel()
_titleLabel.font = UIFont.dynamicTypeFootnoteClamped.semibold()
_titleLabel.textColor = .Signal.label
_titleLabel.numberOfLines = 1
_titleLabel.text = text
return _titleLabel
}
private func buildBodyLabel(text: NSAttributedString) -> UILabel {
let _bodyLabel = UILabel()
_bodyLabel.font = UIFont.dynamicTypeSubheadlineClamped
_bodyLabel.textColor = .Signal.label
_bodyLabel.numberOfLines = 1
_bodyLabel.attributedText = text
return _bodyLabel
}
func makeTextStackThumbnailContainer(
thumbnail: UIImageView?,
title: String,
body: NSAttributedString,
hasLeadingAccessory: Bool,
) -> UIView {
let container = UIView()
let accessoryPadding = hasLeadingAccessory ? PinnedMessageConstants.leadingAccessoryPadding : 0.0
let textStackLeadingPadding: CGFloat
if let thumbnail {
container.addSubview(thumbnail)
thumbnail.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
thumbnail.topAnchor.constraint(equalTo: container.topAnchor, constant: PinnedMessageConstants.thumbnailPadding),
thumbnail.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: PinnedMessageConstants.leadingPadding + accessoryPadding),
thumbnail.widthAnchor.constraint(equalToConstant: PinnedMessageConstants.thumbnailSize),
])
textStackLeadingPadding = PinnedMessageConstants.leadingPadding +
accessoryPadding +
PinnedMessageConstants.thumbnailSize +
PinnedMessageConstants.spacing
} else {
textStackLeadingPadding = PinnedMessageConstants.leadingPadding + accessoryPadding
}
let _titleLabel = buildTitleLabel(text: title)
let _bodyLabel = buildBodyLabel(text: body)
let textStack = UIStackView(arrangedSubviews: [_titleLabel, _bodyLabel])
textStack.axis = .vertical
container.addSubview(textStack)
textStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: textStackLeadingPadding),
textStack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -PinnedMessageConstants.pinButtonTrailingPadding),
])
textStack.isAccessibilityElement = true
let axLabelPrefix = OWSLocalizedString(
"PINNED_MESSAGE_BANNER_AX_LABEL",
comment: "Accessibility label prefix for banner showing a pinned message",
)
textStack.accessibilityLabel = axLabelPrefix + title + "," + body.string
textStack.accessibilityTraits = .button
return container
}
private func rebuildContent() {
directionalLayoutMargins = NSDirectionalEdgeInsets(hMargin: 16, vMargin: 6)
removeAllSubviews()
guard let configuration = self.configuration as? ContentConfiguration else { return }
if let leadingAccessoryView = configuration.leadingAccessoryView {
let leadingAccessoryContainerView = UIView.container()
leadingAccessoryContainerView.addSubview(leadingAccessoryView)
leadingAccessoryView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
leadingAccessoryView.topAnchor.constraint(greaterThanOrEqualTo: leadingAccessoryContainerView.topAnchor),
leadingAccessoryView.centerYAnchor.constraint(equalTo: leadingAccessoryContainerView.centerYAnchor),
leadingAccessoryView.leadingAnchor.constraint(equalTo: leadingAccessoryContainerView.leadingAnchor),
leadingAccessoryView.trailingAnchor.constraint(equalTo: leadingAccessoryContainerView.trailingAnchor),
])
addArrangedSubview(leadingAccessoryContainerView)
}
if configuration.totalPinnedMessageCount > 0, let title = configuration.title {
// If this is a pinned message and its not first load (no previous title), animate the existing banner off screen.
if !titleLabel.text.isEmptyOrNil {
animatePinnedMessageTransition(
newTitle: title,
newBody: configuration.body,
newThumbnail: configuration.thumbnail,
)
} else {
// Otherwise build from scratch.
let container = makeTextStackThumbnailContainer(
thumbnail: configuration.thumbnail,
title: title,
body: configuration.body,
hasLeadingAccessory: configuration.leadingAccessoryView != nil,
)
// Store copies of old banner strings for the next animation.
titleLabel = buildTitleLabel(text: title)
bodyLabel = buildBodyLabel(text: configuration.body)
addSubview(container)
container.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
container.leadingAnchor.constraint(equalTo: leadingAnchor),
container.trailingAnchor.constraint(equalTo: trailingAnchor),
container.topAnchor.constraint(equalTo: topAnchor),
container.heightAnchor.constraint(equalToConstant: PinnedMessageConstants.bannerHeight),
])
}
} else {
bodyLabel = UILabel()
bodyLabel.font = UIFont.dynamicTypeSubheadlineClamped
bodyLabel.textColor = .Signal.label
bodyLabel.numberOfLines = 0
bodyLabel.attributedText = configuration.body
addArrangedSubview(bodyLabel)
}
if
let viewButtonTitle = configuration.viewButtonTitle,
let viewButtonAction = configuration.viewButtonAction
{
let button = UIButton(configuration: .gray(), primaryAction: viewButtonAction)
button.configuration?.title = viewButtonTitle
button.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadlineClamped.medium())
button.configuration?.cornerStyle = .capsule
button.configuration?.contentInsets = .init(hMargin: 15, vMargin: 7)
button.configuration?.baseForegroundColor = .Signal.label
button.configuration?.baseBackgroundColor = .Signal.secondaryFill
button.setCompressionResistanceHigh()
button.setContentHuggingHigh()
let buttonContainer = UIView.container()
buttonContainer.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.topAnchor.constraint(greaterThanOrEqualTo: buttonContainer.topAnchor),
button.centerYAnchor.constraint(equalTo: buttonContainer.centerYAnchor),
button.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor),
button.trailingAnchor.constraint(equalTo: buttonContainer.trailingAnchor),
])
addArrangedSubview(buttonContainer)
setCustomSpacing(8, after: buttonContainer)
directionalLayoutMargins.trailing = 10 // match top and bottom margins
}
if let dismissButtonAction = configuration.dismissButtonAction {
let button = UIButton(configuration: .plain(), primaryAction: dismissButtonAction)
button.tintColor = .Signal.label
button.configuration?.image = UIImage(named: "x-20")
button.configuration?.cornerStyle = .capsule
button.configuration?.contentInsets = .init(margin: 6)
button.configuration?.baseBackgroundColor = .Signal.secondaryFill
button.accessibilityLabel = OWSLocalizedString(
"BANNER_CLOSE_ACCESSIBILITY_LABEL",
comment: "Accessibility label for banner close button",
)
button.setCompressionResistanceHigh()
button.setContentHuggingHigh()
let buttonContainer = UIView.container()
buttonContainer.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.topAnchor.constraint(greaterThanOrEqualTo: buttonContainer.topAnchor),
button.centerYAnchor.constraint(equalTo: buttonContainer.centerYAnchor),
button.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor),
button.trailingAnchor.constraint(equalTo: buttonContainer.trailingAnchor),
])
addArrangedSubview(buttonContainer)
directionalLayoutMargins.trailing = 4 // 10 total with button's content padding
}
if configuration.totalPinnedMessageCount > 0 {
let button: UIButton
if #available(iOS 26.0, *) {
button = UIButton(configuration: .clearGlass())
} else {
button = UIButton(configuration: .plain())
}
button.configuration?.image = .pin
button.configuration?.cornerStyle = .capsule
button.configuration?.contentInsets = .init(margin: 6)
button.accessibilityLabel = OWSLocalizedString(
"PINNED_MESSAGE_MENU_ACCESSIBILITY_LABEL",
comment: "Accessibility label for pin message button",
)
button.setCompressionResistanceHigh()
button.setContentHuggingHigh()
button.menu = pinMessageMenu()
button.showsMenuAsPrimaryAction = true
button.tintColor = .Signal.label
let buttonContainer = UIView.container()
buttonContainer.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.topAnchor.constraint(greaterThanOrEqualTo: buttonContainer.topAnchor),
button.centerYAnchor.constraint(equalTo: buttonContainer.centerYAnchor),
button.leadingAnchor.constraint(equalTo: buttonContainer.leadingAnchor),
button.trailingAnchor.constraint(equalTo: buttonContainer.trailingAnchor),
])
addSubview(buttonContainer)
buttonContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
buttonContainer.centerYAnchor.constraint(equalTo: centerYAnchor),
buttonContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
])
// Total banner height should always be 50 for pinned messages.
NSLayoutConstraint.activate([
heightAnchor.constraint(equalToConstant: PinnedMessageConstants.bannerHeight),
])
}
if configuration.bannerTapAction != nil {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapBanner)))
}
}
private func pinMessageMenu() -> UIMenu {
var actions: [UIAction] = []
guard let configuration = self.configuration as? ContentConfiguration else { return UIMenu() }
if !configuration.isTerminatedGroup {
actions.append(UIAction(
title: OWSLocalizedString(
"PINNED_MESSAGES_UNPIN",
comment: "Action menu item to unpin a message",
),
image: .pinSlash,
) { [weak self] _ in
self?.pinnedMessageInteractionDelegate?.unpinMessage(message: nil, modalDelegate: nil)
})
}
actions.append(
contentsOf: [
UIAction(
title: OWSLocalizedString(
"PINNED_MESSAGES_GO_TO_MESSAGE",
comment: "Action menu item to go to a message in the conversation view",
),
image: .chatArrow,
) { [weak self] _ in
self?.pinnedMessageInteractionDelegate?.goToMessage(message: nil)
},
UIAction(title: OWSLocalizedString(
"PINNED_MESSAGES_SEE_ALL_MESSAGES",
comment: "Action menu item to see all pinned messages",
), image: .listBullet) { [weak self] _ in
self?.pinnedMessageInteractionDelegate?.presentSeeAllMessages()
},
],
)
return UIMenu(children: actions)
}
private func animatePinnedMessageTransition(
newTitle: String,
newBody: NSAttributedString,
newThumbnail: UIImageView?,
) {
guard
let titleText = titleLabel.text,
let bodyText = bodyLabel.attributedText
else {
return
}
// Make container for old textStack
let oldContainer = makeTextStackThumbnailContainer(
thumbnail: thumbnail,
title: titleText,
body: bodyText,
hasLeadingAccessory: true,
)
addSubview(oldContainer)
oldContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
oldContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
oldContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
oldContainer.topAnchor.constraint(equalTo: topAnchor),
oldContainer.heightAnchor.constraint(equalToConstant: PinnedMessageConstants.bannerHeight),
])
// Build container for new textStack
let newContainer = makeTextStackThumbnailContainer(
thumbnail: newThumbnail,
title: newTitle,
body: newBody,
hasLeadingAccessory: true,
)
addSubview(newContainer)
newContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
newContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
newContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
newContainer.topAnchor.constraint(equalTo: topAnchor),
newContainer.heightAnchor.constraint(equalToConstant: PinnedMessageConstants.bannerHeight),
])
// Initial positions
oldContainer.transform = .identity
newContainer.transform = CGAffineTransform(translationX: 0, y: PinnedMessageConstants.bannerHeight)
let pinAnimator = UIViewPropertyAnimator(
duration: 0.3,
springDamping: 1,
springResponse: 0.3,
)
pinAnimator.addAnimations {
oldContainer.transform = CGAffineTransform(translationX: 0, y: -PinnedMessageConstants.bannerHeight)
newContainer.transform = .identity
}
pinAnimator.addCompletion { _ in
self.isAnimating = false
oldContainer.removeFromSuperview()
// Store the new text & thumbnails so we can reference them
// as the "old" ones next time we animate.
self.titleLabel = self.buildTitleLabel(text: newTitle)
self.bodyLabel = self.buildBodyLabel(text: newBody)
self.thumbnail = newThumbnail
}
isAnimating = true
pinAnimator.startAnimation()
}
@objc
private func didTapBanner() {
guard let configuration = self.configuration as? ContentConfiguration, let bannerTapAction = configuration.bannerTapAction else { return }
bannerTapAction()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func supports(_ configuration: any UIContentConfiguration) -> Bool {
return configuration is ContentConfiguration
}
}
init(configuration: ContentConfiguration) {
contentView = configuration.makeContentView()
super.init(frame: .zero)
let backgroundView: UIView
if #available(iOS 26, *) {
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.tintColor = .Signal.glassBackgroundTint
backgroundView = UIVisualEffectView(effect: glassEffect)
backgroundView.cornerConfiguration = .capsule()
backgroundView.clipsToBounds = true
} else {
if UIAccessibility.isReduceTransparencyEnabled {
backgroundView = UIView()
backgroundView.backgroundColor = Theme.secondaryBackgroundColor
} else {
backgroundView = UIVisualEffectView(effect: Theme.barBlurEffect)
}
backgroundView.layer.masksToBounds = true
backgroundView.layer.cornerRadius = 16
if Theme.isDarkThemeEnabled {
layer.shadowColor = UIColor.white.cgColor
layer.shadowOpacity = 0.16
} else {
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.08
}
layer.shadowRadius = 24
layer.shadowOffset = .init(width: 0, height: 4)
}
addSubview(backgroundView)
if let visualEffectView = backgroundView as? UIVisualEffectView {
visualEffectView.contentView.addSubview(contentView)
blurBackgroundView = visualEffectView
} else {
addSubview(contentView)
}
backgroundView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Background view.
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
// Content view.
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
func isAnimating() -> Bool {
guard let bannerContentView = contentView as? ConversationBannerContentView else {
return false
}
return bannerContentView.isAnimating
}
func animateBannerFadeIn() {
guard let contentView = contentView as? ConversationBannerContentView else {
return
}
let animator = ConversationBannerView.fadeInAnimator()
UIView.performWithoutAnimation {
blurBackgroundView?.effect = nil
contentView.bodyLabel.alpha = 0
contentView.titleLabel.alpha = 0
contentView.thumbnail?.alpha = 0
}
animator.addAnimations {
if let backgroundViewEffect = self.backgroundViewVisualEffect() {
self.blurBackgroundView?.effect = backgroundViewEffect
}
contentView.bodyLabel.alpha = 1
contentView.titleLabel.alpha = 1
contentView.thumbnail?.alpha = 1
}
animator.startAnimation()
}
func animateBannerFadeOut(completion: @escaping (UIViewAnimatingPosition) -> Void) {
guard let contentView = contentView as? ConversationBannerContentView else {
return
}
let animator = ConversationBannerView.fadeInAnimator()
UIView.performWithoutAnimation {
if let backgroundViewEffect = self.backgroundViewVisualEffect() {
self.blurBackgroundView?.effect = backgroundViewEffect
}
contentView.bodyLabel.alpha = 1
contentView.titleLabel.alpha = 1
contentView.thumbnail?.alpha = 1
}
animator.addAnimations {
self.blurBackgroundView?.effect = nil
contentView.bodyLabel.alpha = 0
contentView.titleLabel.alpha = 0
contentView.thumbnail?.alpha = 0
}
animator.addCompletion(completion)
animator.startAnimation()
}
private func backgroundViewVisualEffect() -> UIVisualEffect? {
if #available(iOS 26, *) {
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.tintColor = .Signal.glassBackgroundTint
return glassEffect
}
guard !UIAccessibility.isReduceTransparencyEnabled else { return nil }
let blurEffect = Theme.barBlurEffect
return blurEffect
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Name Collision Banners
private extension ConversationViewController {
func createMessageRequestNameCollisionBanner(viewState: CVViewState) -> ConversationBannerView? {
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 bannerConfiguration = ConversationBannerView.ContentConfiguration(
title: nil,
body: 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",
).attributedString(),
thumbnail: nil,
viewButtonTitle: CommonStrings.viewButton,
viewButtonAction: UIAction { [weak self] _ in
guard let self else { return }
let vc = NameCollisionResolutionViewController(collisionFinder: collisionFinder, collisionDelegate: self)
vc.present(fromViewController: self)
},
dismissButtonAction: UIAction { [weak self] _ in
guard let self else { return }
SSKEnvironment.shared.databaseStorageRef.write {
viewState.hideMessageRequestNameCollisionBanner(transaction: $0)
}
self.ensureBannerState()
},
leadingAccessoryView: DoubleProfileImageView(primaryImage: avatar1, secondaryImage: avatar2),
totalPinnedMessageCount: 0,
isTerminatedGroup: thread.isTerminatedGroup,
)
return ConversationBannerView(configuration: bannerConfiguration)
}
func createGroupMembershipCollisionBanner() -> ConversationBannerView? {
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
Task.detached(priority: .userInitiated) {
SSKEnvironment.shared.databaseStorageRef.read { readTx in
// Prewarm our collision finder off the main thread
_ = collisionFinder.findCollisions(transaction: readTx)
}
await self.ensureBannerState()
}
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 bannerConfiguration = ConversationBannerView.ContentConfiguration(
title: nil,
body: title.attributedString(),
thumbnail: nil,
viewButtonTitle: CommonStrings.viewButton,
viewButtonAction: UIAction { [weak self] _ in
guard let self else { return }
let vc = NameCollisionResolutionViewController(
collisionFinder: collisionFinder,
collisionDelegate: self,
)
vc.present(fromViewController: self)
},
dismissButtonAction: UIAction { [weak self] _ in
guard let self else { return }
SSKEnvironment.shared.databaseStorageRef.asyncWrite(
block: { writeTx in
collisionFinder.markCollisionsAsResolved(transaction: writeTx)
},
completion: {
self.ensureBannerState()
},
)
},
leadingAccessoryView: DoubleProfileImageView(primaryImage: avatar1, secondaryImage: avatar2),
totalPinnedMessageCount: 0,
isTerminatedGroup: thread.isTerminatedGroup,
)
return ConversationBannerView(configuration: bannerConfiguration)
}
private func fetchAvatar(for address: SignalServiceAddress, tx: DBReadTransaction) -> UIImage? {
return SSKEnvironment.shared.avatarBuilderRef.avatarImage(
forAddress: address,
diameterPoints: 24,
localUserDisplayMode: .asUser,
transaction: tx,
)
}
private class DoubleProfileImageView: UIView {
init(primaryImage: UIImage?, secondaryImage: UIImage?) {
super.init(frame: .zero)
addSubview(secondaryImageView)
addSubview(primaryImageView)
if let primaryImage {
primaryImageView.image = primaryImage
}
if let secondaryImage {
secondaryImageView.image = secondaryImage
}
primaryImageView.translatesAutoresizingMaskIntoConstraints = false
secondaryImageView.translatesAutoresizingMaskIntoConstraints = false
let hasSecondaryImage = (secondaryImage != nil)
let top = primaryImageView.topAnchor.constraint(
equalTo: topAnchor,
constant: hasSecondaryImage ? 12 : 0,
)
let leading = primaryImageView.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: hasSecondaryImage ? 16 : 4,
)
NSLayoutConstraint.activate([
// Image sizes.
primaryImageView.widthAnchor.constraint(equalToConstant: Constants.avatarSize.width),
primaryImageView.heightAnchor.constraint(equalToConstant: Constants.avatarSize.height),
secondaryImageView.widthAnchor.constraint(equalToConstant: Constants.avatarSize.width),
secondaryImageView.heightAnchor.constraint(equalToConstant: Constants.avatarSize.height),
// Position of the primary image.
top,
leading,
primaryImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
primaryImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
// Secondary image is always offset to the top left of the primary image
secondaryImageView.topAnchor.constraint(equalTo: primaryImageView.topAnchor, constant: -12),
secondaryImageView.leadingAnchor.constraint(equalTo: primaryImageView.leadingAnchor, constant: -12),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private enum Constants {
static let avatarSize = CGSize(square: 24)
static let avatarBorderSize: CGFloat = 2
}
private let primaryImageView: UIImageView = {
let imageView = UIImageView.withTemplateImageName("info", tintColor: .Signal.secondaryLabel)
imageView.layer.cornerRadius = Constants.avatarSize.smallerAxis / 2
imageView.layer.masksToBounds = true
return imageView
}()
private let secondaryImageView: UIImageView = {
let imageView = SecondaryImageView()
imageView.layer.cornerRadius = Constants.avatarSize.smallerAxis / 2
imageView.layer.masksToBounds = true
return imageView
}()
private class SecondaryImageView: UIImageView {
override func layoutSubviews() {
// 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 circlePath = UIBezierPath(
ovalIn: CGRect(
origin: CGPoint(
x: bounds.center.x - borderSize,
y: bounds.center.y - borderSize,
),
size: bounds.size.plus(.square(borderSize * 2)),
),
)
let maskPath = UIBezierPath(rect: bounds)
maskPath.append(circlePath)
let maskLayer = CAShapeLayer()
maskLayer.path = maskPath.cgPath
// Mask the inverse of the circle path
maskLayer.fillRule = .evenOdd
layer.mask = maskLayer
}
}
}
}
// MARK: - Pending Group Join Requests
private extension ConversationViewController {
func createPendingJoinRequestBanner(viewState: CVViewState) -> ConversationBannerView? {
guard
let pendingMemberRequests,
!pendingMemberRequests.isEmpty,
canApprovePendingMemberRequests
else {
return nil
}
guard !thread.isTerminatedGroup else {
return nil
}
// We will skip this read if the above checks fail, which will be most of the time.
guard
SSKEnvironment.shared.databaseStorageRef.read(block: { tx in
viewState.shouldShowPendingMemberRequestsBanner(
currentPendingMembers: pendingMemberRequests,
transaction: tx,
)
})
else {
return nil
}
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 bannerConfiguration = ConversationBannerView.ContentConfiguration(
title: nil,
body: String.localizedStringWithFormat(format, pendingMemberRequests.count).attributedString(),
thumbnail: nil,
viewButtonTitle: CommonStrings.viewButton,
viewButtonAction: UIAction { [weak self] _ in
self?.showConversationSettingsAndShowMemberRequests()
},
dismissButtonAction: UIAction { [weak self] _ in
SSKEnvironment.shared.databaseStorageRef.write { transaction in
viewState.hidePendingMemberRequestsBanner(
currentPendingMembers: pendingMemberRequests,
transaction: transaction,
)
}
self?.ensureBannerState()
},
leadingAccessoryView: {
let imageView = UIImageView(image: UIImage(named: "group"))
imageView.tintColor = .Signal.label
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
return imageView
}(),
totalPinnedMessageCount: 0,
isTerminatedGroup: thread.isTerminatedGroup,
)
return ConversationBannerView(configuration: bannerConfiguration)
}
private var pendingMemberRequests: Set<SignalServiceAddress>? {
guard let groupThread = thread as? TSGroupThread else { return nil }
return groupThread.groupMembership.requestingMembers
}
private var canApprovePendingMemberRequests: Bool {
guard let groupThread = thread as? TSGroupThread else { return false }
return groupThread.groupModel.groupMembership.isLocalUserFullMemberAndAdministrator
}
}
// MARK: - No Longer Verified
private extension ConversationViewController {
func createNoLongerVerifiedStateBanner() -> ConversationBannerView? {
let noLongerVerifiedIdentityKeys = SSKEnvironment.shared.databaseStorageRef.read { tx in
self.noLongerVerifiedIdentityKeys(tx: tx)
}
guard !noLongerVerifiedIdentityKeys.isEmpty else { return nil }
let title: String
switch noLongerVerifiedIdentityKeys.count {
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}}.",
)
title = String.nonPluralLocalizedStringWithFormat(format, displayName)
default:
title = OWSLocalizedString(
"MESSAGES_VIEW_N_MEMBERS_NO_LONGER_VERIFIED",
comment: "Indicates that more than one member of this group conversation is no longer verified.",
)
}
let bannerConfiguration = ConversationBannerView.ContentConfiguration(
title: nil,
body: title.attributedString(),
thumbnail: nil,
viewButtonTitle: CommonStrings.viewButton,
viewButtonAction: UIAction { [weak self] _ in
self?.noLongerVerifiedBannerViewWasTapped(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
},
dismissButtonAction: UIAction { [weak self] _ in
self?.resetVerificationStateToDefault(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
},
leadingAccessoryView: {
let imageView = UIImageView(image: UIImage(named: "safety-number"))
imageView.tintColor = .Signal.label
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
return imageView
}(),
totalPinnedMessageCount: 0,
isTerminatedGroup: thread.isTerminatedGroup,
)
return ConversationBannerView(configuration: bannerConfiguration)
}
private func noLongerVerifiedBannerViewWasTapped(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
AssertIsOnMainThread()
guard !noLongerVerifiedIdentityKeys.isEmpty else { return }
let title: String
switch noLongerVerifiedIdentityKeys.count {
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,
style: .cancel,
) { [weak self] _ in
self?.resetVerificationStateToDefault(noLongerVerifiedIdentityKeys: noLongerVerifiedIdentityKeys)
})
dismissKeyBoard()
presentActionSheet(actionSheet)
}
}
// MARK: - Pinned Messages
extension ConversationViewController {
func animateToNextPinnedMessage() {
guard
let nextPinnedMessage = createPinnedMessageBannerIfNecessary(),
let priorPinnedMessage = bannerStackView?.arrangedSubviews.last as? ConversationBannerView
else {
return
}
priorPinnedMessage.contentView.configuration = nextPinnedMessage.contentView.configuration
}
/// Displays the first pinned message, sorted by most recently pinned.
/// When tapped, it cycles to the next pinned message if one exists.
private func createPinnedMessageBannerIfNecessary() -> ConversationBannerView? {
guard
threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex),
let pinnedMessageData = pinnedMessageData(for: threadViewModel.pinnedMessages[pinnedMessageIndex])
else {
return nil
}
let bannerConfiguration = ConversationBannerView.ContentConfiguration(
title: pinnedMessageData.authorName,
body: pinnedMessageData.previewText,
thumbnail: pinnedMessageData.thumbnail,
viewButtonTitle: nil,
viewButtonAction: nil,
dismissButtonAction: nil,
leadingAccessoryView: pinnedMessageLeadingAccessoryView(),
bannerTapAction: { [weak self] in
guard let priorPinnedMessage = self?.bannerStackView?.arrangedSubviews.last as? ConversationBannerView else {
return
}
if !priorPinnedMessage.isAnimating() {
self?.handleTappedPinnedMessage()
}
},
totalPinnedMessageCount: threadViewModel.pinnedMessages.count,
isTerminatedGroup: thread.isTerminatedGroup,
)
let banner = ConversationBannerView(configuration: bannerConfiguration)
let longPressInteraction = UIContextMenuInteraction(delegate: self)
banner.blurBackgroundView?.addInteraction(longPressInteraction)
// Set up interaction delegate for pin icon menu
banner.pinnedMessageDelegate = self
return banner
}
}