Signal-iOS/Signal/src/ViewControllers/ConversationView/ConversationViewController+OWS.swift
2021-07-13 11:42:52 -03:00

540 lines
21 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
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 {
self.databaseStorage.write { transaction in
self.threadViewModel.associatedData.updateWith(
isMarkedUnread: false,
updateStorageService: true,
transaction: transaction
)
}
}
}
@objc(canCallThreadViewModel:)
public static func canCall(threadViewModel: ThreadViewModel) -> Bool {
let thread = threadViewModel.threadRecord
guard thread.isLocalUserFullMemberOfThread else {
return false
}
guard !threadViewModel.hasPendingMessageRequest else {
return false
}
guard let contactThread = thread as? TSContactThread else {
return RemoteConfig.groupCalling && thread.isGroupV2Thread
}
guard !contactThread.isNoteToSelf else {
return false
}
return true
}
// MARK: -
@objc(updateContentInsetsAnimated:)
public func updateContentInsets(animated: Bool) {
AssertIsOnMainThread()
guard !isMeasuringKeyboardHeight else {
return
}
// Don't update the content insets if an interactive pop is in progress
guard let navigationController = self.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
let keyboardOverlap = inputAccessoryPlaceholder.keyboardOverlap
newInsets.bottom = (messageActionsExtraContentInsetPadding +
keyboardOverlap +
bottomBar.height -
view.safeAreaInsets.bottom)
newInsets.top = messageActionsExtraContentInsetPadding + (bannerView?.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
}
// Adjust content offset to prevent the presented keyboard from obscuring content.
if !didChangeInsets {
// Do nothing.
//
// If content inset didn't change, no need to update content offset.
} else if !hasAppearedAndHasAppliedFirstLoad {
// 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.
owsAssertDebug(topLayoutGuide.length == view.safeAreaInsets.top)
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
? NSLocalizedString("ALERT_UNKNOWN_THREAD_WARNING_GROUP_MESSAGE",
comment: "Message for UI warning about an unknown group thread.")
: NSLocalizedString("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: NSLocalizedString("ALERT_UNKNOWN_THREAD_WARNING_LEARN_MORE",
comment: "Label for button to learn more about message requests."),
style: .default,
handler: { _ in
// TODO: Finalize this behavior.
let url = URL(string: "https://support.signal.org/hc/articles/360007459591")!
UIApplication.shared.open(url, options: [:])
}
))
actionSheet.addAction(OWSActionSheets.cancelAction)
presentActionSheet(actionSheet)
}
}
// MARK: - ForwardMessageDelegate
extension ConversationViewController: ForwardMessageDelegate {
public func forwardMessageFlowDidComplete(itemViewModel: CVItemViewModelImpl, threads: [TSThread]) {
self.dismiss(animated: true) { [weak self] in
guard let self = self else { return }
guard let thread = threads.first,
thread.uniqueId != self.thread.uniqueId else {
return
}
SignalApp.shared().presentConversation(for: thread, animated: true)
}
}
public func forwardMessageFlowDidCancel() {
self.dismiss(animated: true)
}
}
// MARK: -
extension ConversationViewController {
// A message can be remotely deleted iff:
// * the feature flag is enabled
// * you sent this message
// * you haven't already remotely deleted this message
// * it has been less than 3 hours since you sent the message
func canBeRemotelyDeleted(item: CVItemViewModel) -> Bool {
guard let outgoingMessage = item.interaction as? TSOutgoingMessage else { return false }
guard !outgoingMessage.wasRemotelyDeleted else { return false }
guard Date.ows_millisecondTimestamp() - outgoingMessage.timestamp <= (kHourInMs * 3) else { return false }
return true
}
func showDeleteForEveryoneConfirmationIfNecessary(completion: @escaping () -> Void) {
guard !Self.preferences.wasDeleteForEveryoneConfirmationShown() else { return completion() }
OWSActionSheets.showConfirmationAlert(
title: NSLocalizedString(
"MESSAGE_ACTION_DELETE_FOR_EVERYONE_CONFIRMATION",
comment: "A one-time confirmation that you want to delete for everyone"
),
proceedTitle: NSLocalizedString(
"MESSAGE_ACTION_DELETE_FOR_EVERYONE",
comment: "The title for the action that deletes a message for all users in the conversation."
),
proceedStyle: .destructive) { _ in
Self.preferences.setWasDeleteForEveryoneConfirmationShown()
completion()
}
}
}
// MARK: - MessageActionsToolbarDelegate
extension ConversationViewController: MessageActionsToolbarDelegate {
public func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction) {
executedAction.block(messageActionsToolbar)
}
}
// 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) {
switch oldValue {
case .normal:
// no-op
break
case .search:
if #available(iOS 13.0, *) {
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()
}
case .selection:
break
}
switch uiMode {
case .normal:
if navigationItem.titleView != headerView {
navigationItem.titleView = headerView
}
case .search:
if #available(iOS 13.0, *) {
navigationItem.searchController = searchController.uiSearchController
} else {
// Note: setting a searchBar as the titleView causes UIKit to render the navBar
// *slightly* taller (44pt -> 56pt)
navigationItem.titleView = searchController.uiSearchController.searchBar
}
case .selection:
navigationItem.titleView = nil
}
updateBarButtonItems()
ensureBottomViewType()
}
}
// MARK: -
extension ConversationViewController: MessageActionsViewControllerDelegate {
public func messageActionsViewControllerRequestedKeyboardDismissal(_ messageActionsViewController: MessageActionsViewController,
focusedView: UIView) {
dismissKeyBoard()
// After dismissing the keyboard, it's important we update the message actions
// state. We keep track of the content offset at the time of presenting a message
// action to ensure that new messages / typing indicators don't cause the
// focused message to move. That offset is now different since the focused message
// may be repositioning.
updateMessageActionsState(forCell: focusedView)
}
public func messageActionsViewControllerRequestedDismissal(_ messageActionsViewController: MessageActionsViewController,
withAction action: MessageAction?) {
let sender: UIView? = {
let interaction = messageActionsViewController.focusedInteraction
guard let indexPath = indexPath(forInteractionUniqueId: interaction.uniqueId) else {
return nil
}
guard self.collectionView.indexPathsForVisibleItems.contains(indexPath),
let cell = self.collectionView.cellForItem(at: indexPath) as? CVCell else {
return nil
}
// TODO: Should we use a more specific cell view?
return cell
}()
dismissMessageActions(animated: true) {
action?.block(sender)
}
}
public func messageActionsViewControllerRequestedDismissal(_ messageActionsViewController: MessageActionsViewController,
withReaction reaction: String,
isRemoving: Bool) {
dismissMessageActions(animated: true) {
guard let message = messageActionsViewController.focusedInteraction as? TSMessage else {
owsFailDebug("Not sending reaction for unexpected interaction type")
return
}
self.databaseStorage.asyncWrite { transaction in
ReactionManager.localUserReactedWithDurableSend(to: message,
emoji: reaction,
isRemoving: isRemoving,
transaction: transaction)
}
}
}
public func messageActionsViewController(_ messageActionsViewController: MessageActionsViewController,
shouldShowReactionPickerForInteraction: TSInteraction) -> Bool {
guard !threadViewModel.hasPendingMessageRequest else { return false }
guard threadViewModel.isLocalUserFullMemberOfThread else { return false }
switch messageActionsViewController.focusedInteraction {
case let outgoingMessage as TSOutgoingMessage:
if outgoingMessage.wasRemotelyDeleted { return false }
switch outgoingMessage.messageState {
case .failed, .sending, .pending:
return false
default:
return true
}
case let incomingMessage as TSIncomingMessage:
if incomingMessage.wasRemotelyDeleted { return false }
return true
default:
return false
}
}
public func messageActionsViewControllerLongPressGestureRecognizer(_ messageActionsViewController: MessageActionsViewController) -> UILongPressGestureRecognizer {
return collectionViewLongPressGestureRecognizer
}
}
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)
// TODO exactly match corner radius for collapsed cells - maybe requires passing a masking view?
return MediaPresentationContext(mediaView: mediaView,
presentationFrame: presentationFrame,
cornerRadius: CVComponentMessage.bubbleSharpCornerRadius * 2)
}
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
return nil
}
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.
let mediaOverlayViews = toContext.mediaOverlayViews
for mediaOverlayView in mediaOverlayViews {
mediaOverlayView.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
let duration: TimeInterval = kIsDebuggingMediaPresentationAnimations ? 1.5 : 0.2
UIView.animate(
withDuration: duration,
animations: {
for mediaOverlayView in mediaOverlayViews {
mediaOverlayView.alpha = 1
}
})
}
}
// MARK: -
public extension ConversationViewController {
func showManualMigrationAlert(groupThread: TSGroupThread,
migrationInfo: GroupsV2MigrationInfo) {
let mode = GroupMigrationActionSheet.Mode.upgradeGroup(migrationInfo: migrationInfo)
let view = GroupMigrationActionSheet(groupThread: groupThread, mode: mode)
view.present(fromViewController: self)
}
func showGroupLinkPromotionActionSheet() {
guard let groupThread = thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return
}
guard groupThread.isGroupV2Thread else {
return
}
let view = GroupLinkPromotionActionSheet(groupThread: groupThread,
conversationViewController: self)
view.present(fromViewController: self)
}
}
// MARK: -
extension ConversationViewController: MessageDetailViewDelegate {
func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController) {
Logger.info("")
navigationController?.popToViewController(self, animated: true)
}
}
// MARK: -
extension ConversationViewController: LongTextViewDelegate {
public func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) {
Logger.info("")
navigationController?.popToViewController(self, animated: true)
}
public func expandTruncatedTextOrPresentLongTextView(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
guard let displayableBodyText = itemViewModel.displayableBodyText else {
owsFailDebug("Missing displayableBodyText.")
return
}
if displayableBodyText.canRenderTruncatedTextInline {
self.setTextExpanded(interactionId: itemViewModel.interaction.uniqueId)
// TODO: Verify scroll state continuity.
// Alternately we could use load coordinator and specify a scroll action.
databaseStorage.write { transaction in
self.databaseStorage.touch(interaction: itemViewModel.interaction,
shouldReindex: false,
transaction: transaction)
}
} else {
let viewController = LongTextViewController(itemViewModel: itemViewModel)
viewController.delegate = self
navigationController?.pushViewController(viewController, animated: true)
}
}
}
// MARK: -
extension ConversationViewController: SendPaymentViewDelegate {
public func didSendPayment() {
let paymentSettingsView = PaymentsSettingsViewController(mode: .standalone)
let navigationController = OWSNavigationController(rootViewController: paymentSettingsView)
presentFormSheet(navigationController, animated: true)
}
}