Path: blob/main/Signal/ConversationView/ConversationViewController+OWS.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public import SignalUI
extension ConversationViewController {
public func renderItem(forIndex index: NSInteger) -> CVRenderItem? {
guard index >= 0, index < renderItems.count else {
owsFailDebug("Invalid view item index: \(index)")
return nil
}
return renderItems[index]
}
var renderState: CVRenderState {
AssertIsOnMainThread()
return loadCoordinator.renderState
}
public var renderItems: [CVRenderItem] {
AssertIsOnMainThread()
return loadCoordinator.renderItems
}
public var allIndexPaths: [IndexPath] {
AssertIsOnMainThread()
return loadCoordinator.allIndexPaths
}
func ensureIndexPath(of interaction: TSMessage) -> IndexPath? {
// CVC TODO: This is incomplete.
self.indexPath(forInteractionUniqueId: interaction.uniqueId)
}
func clearThreadUnreadFlagIfNecessary() {
if threadViewModel.isMarkedUnread {
SSKEnvironment.shared.databaseStorageRef.write { transaction in
self.threadViewModel.associatedData.updateWith(
isMarkedUnread: false,
updateStorageService: true,
transaction: transaction,
)
}
}
}
public static func canCall(threadViewModel: ThreadViewModel) -> Bool {
if threadViewModel.hasPendingMessageRequest {
return false
}
switch threadViewModel.threadRecord {
case let thread as TSContactThread:
return thread.canCall
case let thread as TSGroupThread:
return thread.canCall
default:
return false
}
}
// MARK: -
// When performing an interactive dismiss, safe area updates rapidly in quick succession,
// which causes this method to go haywire, recomputing insets a few times and incorrectly determining
// that it needs to scroll as a result. To avoid this, apply a debounce to rapid updates.
public func updateContentInsetsDebounced() {
updateContentInsetsEvent.requestNotify()
}
func updateContentInsets() {
AssertIsOnMainThread()
// Don't update the content insets if an interactive pop is in progress
guard let navigationController else {
return
}
if let interactivePopGestureRecognizer = navigationController.interactivePopGestureRecognizer {
switch interactivePopGestureRecognizer.state {
case .possible, .failed:
break
default:
return
}
}
view.layoutIfNeeded()
let oldInsets = collectionView.contentInset
var newInsets = oldInsets
newInsets.bottom = bottomBarContainer.frame.height - collectionView.safeAreaInsets.bottom
newInsets.top = (bannerStackView?.height ?? 0)
let wasScrolledToBottom = self.isScrolledToBottom
// Changing the contentInset can change the contentOffset, so make sure we
// stash the current value before making any changes.
let oldYOffset = collectionView.contentOffset.y
let didChangeInsets = oldInsets != newInsets
UIView.performWithoutAnimation {
if didChangeInsets {
let contentOffset = self.collectionView.contentOffset
self.collectionView.contentInset = newInsets
self.collectionView.setContentOffset(contentOffset, animated: false)
}
self.collectionView.scrollIndicatorInsets = newInsets
}
// If content inset didn't change, no need to update content offset.
guard didChangeInsets else { return }
// UIKit updates collection view's scroll position when user drags with the keyboard
// We don't need to do anything.
guard !collectionView.isDragging else { return }
// Adjust content offset to prevent the presented keyboard from obscuring content.
if !hasAppearedAndHasAppliedFirstLoad {
// Do nothing.
} else if isPresentingContextMenu {
// Do nothing
} else if wasScrolledToBottom {
// If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom.
scrollToBottomOfLoadWindow(animated: false)
} else if isViewCompletelyAppeared {
// If we were scrolled away from the bottom, shift the content in lockstep with the
// keyboard, up to the limits of the content bounds.
let insetChange = newInsets.bottom - oldInsets.bottom
// Only update the content offset if the inset has changed.
if insetChange != 0 {
// The content offset can go negative, up to the size of the top layout guide.
// This accounts for the extended layout under the navigation bar.
let minYOffset = -view.safeAreaInsets.top
let newYOffset = (oldYOffset + insetChange).clamp(minYOffset, safeContentHeight)
let newOffset = CGPoint(x: 0, y: newYOffset)
// This offset change will be animated by UIKit's UIView animation block
// which updateContentInsets() is called within
collectionView.setContentOffset(newOffset, animated: false)
}
}
}
public func showUnknownThreadWarningAlert() {
// TODO: Finalize this copy.
let message = (
thread.isGroupThread
? OWSLocalizedString(
"ALERT_UNKNOWN_THREAD_WARNING_GROUP_MESSAGE",
comment: "Message for UI warning about an unknown group thread.",
)
: OWSLocalizedString(
"ALERT_UNKNOWN_THREAD_WARNING_CONTACT_MESSAGE",
comment: "Message for UI warning about an unknown contact thread.",
),
)
let actionSheet = ActionSheetController(message: message)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"ALERT_UNKNOWN_THREAD_WARNING_LEARN_MORE",
comment: "Label for button to learn more about message requests.",
),
style: .default,
handler: { _ in
CurrentAppContext().open(URL.Support.profilesAndMessageRequests, completion: nil)
},
))
actionSheet.addAction(OWSActionSheets.cancelAction)
presentActionSheet(actionSheet)
}
public func showDeliveryIssueWarningAlert(from senderAddress: SignalServiceAddress, isKnownThread: Bool) {
let senderName = SSKEnvironment.shared.databaseStorageRef.read { transaction in
SSKEnvironment.shared.contactManagerRef.displayName(for: senderAddress, tx: transaction).resolvedValue()
}
let alertTitle = OWSLocalizedString("ALERT_DELIVERY_ISSUE_TITLE", comment: "Title for delivery issue sheet")
let alertMessageFormat: String
if isKnownThread {
alertMessageFormat = OWSLocalizedString("ALERT_DELIVERY_ISSUE_MESSAGE_FORMAT", comment: "Format string for delivery issue sheet message. Embeds {{ sender name }}.")
} else {
alertMessageFormat = OWSLocalizedString("ALERT_DELIVERY_ISSUE_UNKNOWN_THREAD_MESSAGE_FORMAT", comment: "Format string for delivery issue sheet message where the original thread is unknown. Embeds {{ sender name }}.")
}
let alertMessage = String.nonPluralLocalizedStringWithFormat(alertMessageFormat, senderName)
let headerImageView = UIImageView(image: .init(named: "delivery-issue"))
headerImageView.autoSetDimension(.height, toSize: 110)
headerImageView.autoSetDimension(.width, toSize: 200)
let headerView = UIView()
headerView.addSubview(headerImageView)
headerImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 22)
headerImageView.autoPinEdge(toSuperviewEdge: .bottom)
headerImageView.autoHCenterInSuperview()
let actionSheet = ActionSheetController(
title: alertTitle,
message: alertMessage,
)
actionSheet.customHeader = headerView
actionSheet.addAction(OWSActionSheets.okayAction)
actionSheet.addAction(
ActionSheetAction(
title: CommonStrings.learnMore,
style: .default,
) { _ in
CurrentAppContext().open(URL.Support.deliveryIssue, completion: nil)
},
)
presentActionSheet(actionSheet)
}
}
// MARK: - ForwardMessageDelegate
extension ConversationViewController: ForwardMessageDelegate {
func forwardMessageFlowDidComplete(
items: [ForwardMessageItem],
recipientThreads: [TSThread],
) {
AssertIsOnMainThread()
self.uiMode = .normal
self.dismiss(animated: true) {
ForwardMessageViewController.finalizeForward(
items: items,
recipientThreads: recipientThreads,
fromViewController: self,
)
}
}
func forwardMessageFlowDidCancel() {
self.dismiss(animated: true)
}
}
// MARK: - MessageActionsToolbarDelegate
extension ConversationViewController: MessageActionsToolbarDelegate {
public func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction) {
executedAction.block(messageActionsToolbar)
}
public var messageActionsToolbarSelectedInteractionCount: Int {
self.selectionState.interactionCount
}
}
// MARK: -
extension ConversationViewController: GroupViewHelperDelegate {
func groupViewHelperDidUpdateGroup() {
// Do nothing.
}
var currentGroupModel: TSGroupModel? {
guard let groupThread = self.thread as? TSGroupThread else {
return nil
}
return groupThread.groupModel
}
var fromViewController: UIViewController? {
return self
}
}
// MARK: - UIMode
extension ConversationViewController {
func uiModeDidChange(oldValue: ConversationUIMode) {
if oldValue == .search {
navigationItem.searchController = nil
// HACK: For some reason at this point the OWSNavbar retains the extra space it
// used to house the search bar. This only seems to occur when dismissing
// the search UI when scrolled to the very top of the conversation.
navigationController?.navigationBar.sizeToFit()
}
switch uiMode {
case .normal:
if navigationItem.titleView != headerView {
navigationItem.titleView = headerView
}
case .search:
navigationItem.searchController = searchController.uiSearchController
case .selection:
navigationItem.titleView = nil
}
updateBarButtonItems()
ensureBottomViewType()
}
}
extension ConversationViewController: MediaPresentationContextProvider {
func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
guard case let .gallery(galleryItem) = item else {
owsFailDebug("Unexpected media type")
return nil
}
guard let indexPath = ensureIndexPath(of: galleryItem.message) else {
owsFailDebug("indexPath was unexpectedly nil")
return nil
}
// `indexPath(of:)` can change the load window which requires re-laying out our view
// in order to correctly determine:
// - `indexPathsForVisibleItems`
// - the correct presentation frame
collectionView.layoutIfNeeded()
guard let visibleIndex = collectionView.indexPathsForVisibleItems.firstIndex(of: indexPath) else {
// This could happen if, after presenting media, you navigated within the gallery
// to media not within the collectionView's visible bounds.
return nil
}
guard let messageCell = collectionView.visibleCells[safe: visibleIndex] as? CVCell else {
owsFailDebug("messageCell was unexpectedly nil")
return nil
}
guard let mediaView = messageCell.albumItemView(forAttachment: galleryItem.attachmentStream) else {
owsFailDebug("itemView was unexpectedly nil")
return nil
}
guard let mediaSuperview = mediaView.superview else {
owsFailDebug("mediaSuperview was unexpectedly nil")
return nil
}
let presentationFrame = coordinateSpace.convert(mediaView.frame, from: mediaSuperview)
var roundedCorners = RoundedCorners.all(CVComponentMessage.bubbleWideCornerRadius)
let mediaViewFrame = mediaView.convert(mediaView.bounds, to: messageCell)
var sharpBubbleCorners: UIRectCorner = []
if let componentMessage = messageCell.rootComponent as? CVComponentMessage {
sharpBubbleCorners = UIView.uiRectCorner(forOWSDirectionalRectCorner: componentMessage.sharpCorners)
}
if mediaViewFrame.minY > messageCell.bounds.minY {
// Media isn't aligned to cell's top edge - both top corners are square.
roundedCorners.topLeft = 0
roundedCorners.topRight = 0
} else {
// If media isn't pinned to cell's left edge it's left corners would be square.
if mediaView.frame.minX > mediaSuperview.bounds.minX {
roundedCorners.topLeft = 0
} else if sharpBubbleCorners.contains(.topLeft) {
roundedCorners.topLeft = CVComponentMessage.bubbleSharpCornerRadius
}
// If media isn't pinned to cell's right edge it's right corners would be square.
if mediaView.frame.maxX < mediaSuperview.bounds.maxX {
roundedCorners.topRight = 0
} else if sharpBubbleCorners.contains(.topRight) {
roundedCorners.topRight = CVComponentMessage.bubbleSharpCornerRadius
}
}
if mediaViewFrame.maxY < messageCell.bounds.maxY {
// Media isn't aligned to cell's bottom edge - both bottom corners are square.
roundedCorners.bottomLeft = 0
roundedCorners.bottomRight = 0
} else {
// If media isn't pinned to cell's left edge it's left corners would be square.
if mediaView.frame.minX > mediaSuperview.bounds.minX {
roundedCorners.bottomLeft = 0
} else if sharpBubbleCorners.contains(.bottomLeft) {
roundedCorners.bottomLeft = CVComponentMessage.bubbleSharpCornerRadius
}
// If media isn't pinned to cell's right edge it's right corners would be square.
if mediaView.frame.maxX < mediaSuperview.bounds.maxX {
roundedCorners.bottomRight = 0
} else if sharpBubbleCorners.contains(.bottomRight) {
roundedCorners.bottomRight = CVComponentMessage.bubbleSharpCornerRadius
}
}
// Avoid using `variableRoundedCorners` as much as possible because that doesn't work well
// with spring animations.
let mediaViewShape: MediaViewShape
if roundedCorners.isAllCornerRadiiEqual {
mediaViewShape = .rectangle(roundedCorners.topLeft)
} else {
mediaViewShape = .variableRoundedCorners(roundedCorners)
}
return MediaPresentationContext(
mediaView: mediaView,
presentationFrame: presentationFrame,
mediaViewShape: mediaViewShape,
clippingAreaInsets: collectionView.adjustedContentInset,
)
}
func mediaWillDismiss(toContext: MediaPresentationContext) {
// To avoid flicker when transition view is animated over the message bubble,
// we initially hide the overlaying elements and fade them in.
toContext.mediaOverlayViews.forEach { $0.alpha = 0 }
}
func mediaDidDismiss(toContext: MediaPresentationContext) {
// To avoid flicker when transition view is animated over the message bubble,
// we initially hide the overlaying elements and fade them in.
let mediaOverlayViews = toContext.mediaOverlayViews
UIView.animate(
withDuration: MediaPresentationContext.animationDuration,
animations: {
mediaOverlayViews.forEach { $0.alpha = 1 }
},
)
}
}
// MARK: -
public extension ConversationViewController {
func showGroupLinkPromotionActionSheet() {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return
}
guard groupThread.isGroupV2Thread else {
return
}
if groupThread.isTerminatedGroup {
showUnableToTakeActionInEndedGroupSheet()
return
}
let view = GroupLinkPromotionActionSheet(
groupThread: groupThread,
conversationViewController: self,
)
view.present(fromViewController: self)
}
func showUnableToTakeActionInEndedGroupSheet() {
let alert = ActionSheetController(
title: nil,
message: OWSLocalizedString(
"END_GROUP_ACTION_ERROR",
comment: "Description for error sheet that says the user can no longer take this action because the group has ended.",
),
)
alert.addAction(OWSActionSheets.okayAction)
presentActionSheet(alert)
}
}
// MARK: -
extension ConversationViewController: MessageDetailViewDelegate {
func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController) {
Logger.info("")
navigationController?.popToViewController(self, animated: true)
}
}
// MARK: - MessageEditHistoryViewDelegate
extension ConversationViewController: MessageEditHistoryViewDelegate {
func editHistoryMessageWasDeleted() {
self.dismiss(animated: true)
}
}
// MARK: -
extension ConversationViewController: LongTextViewDelegate {
func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) {
Logger.info("")
navigationController?.popToViewController(self, animated: true)
}
func expandTruncatedTextOrPresentLongTextView(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
guard let displayableBodyText = itemViewModel.displayableBodyText else {
owsFailDebug("Missing displayableBodyText.")
return
}
if displayableBodyText.canRenderTruncatedTextInline {
self.setTextExpanded(interactionId: itemViewModel.interaction.uniqueId)
self.loadCoordinator.enqueueReload(
updatedInteractionIds: [itemViewModel.interaction.uniqueId],
deletedInteractionIds: [],
)
} else {
let viewController = LongTextViewController(
itemViewModel: itemViewModel,
threadViewModel: self.threadViewModel,
spoilerState: self.viewState.spoilerState,
)
viewController.delegate = self
navigationController?.pushViewController(viewController, animated: true)
}
}
}
// MARK: -
extension ConversationViewController: SendPaymentViewDelegate {
public func didSendPayment(success: Bool) {
func paymentSettingsNavigationController() -> OWSNavigationController {
let paymentSettingsView = PaymentsSettingsViewController(mode: .standalone, appReadiness: appReadiness)
return OWSNavigationController(rootViewController: paymentSettingsView)
}
// only prompt users to enable payments lock when successful.
guard success else {
// TODO - Remove when in-chat payment bubble implemented.
self.presentFormSheet(paymentSettingsNavigationController(), animated: true)
return
}
PaymentOnboarding.presentBiometricLockPromptIfNeeded { [weak self] in
// TODO - Remove when in-chat payment bubble implemented.
self?.presentFormSheet(paymentSettingsNavigationController(), animated: true)
}
}
}