// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import QuickLook import SignalServiceKit import SignalMessaging protocol MessageDetailViewDelegate: AnyObject { func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController) } class MessageDetailViewController: OWSTableViewController2 { private enum DetailViewError: Error { case messageWasDeleted } weak var detailDelegate: MessageDetailViewDelegate? // MARK: Properties weak var pushPercentDrivenTransition: UIPercentDrivenInteractiveTransition? private var popPercentDrivenTransition: UIPercentDrivenInteractiveTransition? private var renderItem: CVRenderItem? private var thread: TSThread? { renderItem?.itemModel.thread } private(set) var message: TSMessage private var wasDeleted: Bool = false private var isIncoming: Bool { message as? TSIncomingMessage != nil } private struct MessageRecipientModel { let address: SignalServiceAddress let accessoryText: String let displayUDIndicator: Bool } private let messageRecipients = AtomicOptional<[MessageReceiptStatus: [MessageRecipientModel]]>(nil) private let cellView = CVCellView() private var attachments: [TSAttachment]? private var attachmentStreams: [TSAttachmentStream]? { return attachments?.compactMap { $0 as? TSAttachmentStream } } var hasMediaAttachment: Bool { guard let attachmentStreams = self.attachmentStreams, !attachmentStreams.isEmpty else { return false } return true } private let byteCountFormatter: ByteCountFormatter = ByteCountFormatter() private lazy var contactShareViewHelper: ContactShareViewHelper = { let contactShareViewHelper = ContactShareViewHelper() contactShareViewHelper.delegate = self return contactShareViewHelper }() private var databaseUpdateTimer: Timer? // MARK: Initializers required init( message: TSMessage, thread: TSThread ) { self.message = message super.init() } // MARK: View Lifecycle override func themeDidChange() { super.themeDidChange() refreshContent() } override func viewDidLoad() { super.viewDidLoad() title = NSLocalizedString( "MESSAGE_METADATA_VIEW_TITLE", comment: "Title for the 'message metadata' view." ) databaseStorage.appendDatabaseChangeDelegate(self) // Use our own swipe back animation, since the message // details are presented as a "drawer" type view. let panGesture = DirectionalPanGestureRecognizer(direction: .horizontal, target: self, action: #selector(handlePan)) // Allow panning with trackpad if #available(iOS 13.4, *) { panGesture.allowedScrollTypesMask = .continuous } view.addGestureRecognizer(panGesture) if let interactivePopGestureRecognizer = navigationController?.interactivePopGestureRecognizer { interactivePopGestureRecognizer.require(toFail: panGesture) } tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier) refreshContent() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: nil) { [weak self] _ in self?.refreshContent() } } private func updateTableContents() { let contents = OWSTableContents() contents.addSection(buildMessageSection()) if isIncoming { contents.addSection(buildSenderSection()) } else { buildStatusSections().forEach { contents.addSection($0) } } self.contents = contents } public func buildRenderItem(interactionId: String) -> CVRenderItem? { databaseStorage.read { transaction in guard let interaction = TSInteraction.anyFetch( uniqueId: interactionId, transaction: transaction ) else { owsFailDebug("Missing interaction.") return nil } guard let thread = TSThread.anyFetch( uniqueId: interaction.uniqueThreadId, transaction: transaction ) else { owsFailDebug("Missing thread.") return nil } let chatColor = ChatColors.chatColorForRendering(thread: thread, transaction: transaction) let conversationStyle = ConversationStyle( type: .messageDetails, thread: thread, viewWidth: view.width - (cellOuterInsets.totalWidth + (Self.cellHInnerMargin * 2)), hasWallpaper: false, isWallpaperPhoto: false, chatColor: chatColor ) return CVLoader.buildStandaloneRenderItem( interaction: interaction, thread: thread, conversationStyle: conversationStyle, transaction: transaction ) } } private func buildMessageSection() -> OWSTableSection { guard let renderItem = renderItem else { owsFailDebug("Missing renderItem.") return OWSTableSection() } let messageStack = UIStackView() messageStack.axis = .vertical cellView.reset() cellView.configure(renderItem: renderItem, componentDelegate: self) cellView.isCellVisible = true cellView.autoSetDimension(.height, toSize: renderItem.cellSize.height) let cellContainer = UIView() cellContainer.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) cellContainer.addSubview(cellView) cellView.autoPinHeightToSuperviewMargins() cellView.autoPinEdge(toSuperviewEdge: .leading) cellView.autoPinEdge(toSuperviewEdge: .trailing) messageStack.addArrangedSubview(cellContainer) // Sent time let sentTimeLabel = buildValueLabel( name: NSLocalizedString("MESSAGE_METADATA_VIEW_SENT_DATE_TIME", comment: "Label for the 'sent date & time' field of the 'message metadata' view."), value: DateUtil.formatPastTimestampRelativeToNow(message.timestamp) ) messageStack.addArrangedSubview(sentTimeLabel) sentTimeLabel.isUserInteractionEnabled = true sentTimeLabel.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(didLongPressSent))) if isIncoming { // Received time messageStack.addArrangedSubview(buildValueLabel( name: NSLocalizedString("MESSAGE_METADATA_VIEW_RECEIVED_DATE_TIME", comment: "Label for the 'received date & time' field of the 'message metadata' view."), value: DateUtil.formatPastTimestampRelativeToNow(message.receivedAtTimestamp) )) } if hasMediaAttachment, attachments?.count == 1, let attachment = attachments?.first { if let sourceFilename = attachment.sourceFilename { messageStack.addArrangedSubview(buildValueLabel( name: NSLocalizedString("MESSAGE_METADATA_VIEW_SOURCE_FILENAME", comment: "Label for the original filename of any attachment in the 'message metadata' view."), value: sourceFilename )) } if let formattedByteCount = byteCountFormatter.string(for: attachment.byteCount) { messageStack.addArrangedSubview(buildValueLabel( name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_FILE_SIZE", comment: "Label for file size of attachments in the 'message metadata' view."), value: formattedByteCount )) } else { owsFailDebug("formattedByteCount was unexpectedly nil") } if DebugFlags.messageDetailsExtraInfo { let contentType = attachment.contentType messageStack.addArrangedSubview(buildValueLabel( name: NSLocalizedString("MESSAGE_METADATA_VIEW_ATTACHMENT_MIME_TYPE", comment: "Label for the MIME type of attachments in the 'message metadata' view."), value: contentType )) } } let section = OWSTableSection() section.add(.init( customCellBlock: { let cell = OWSTableItem.newCell() cell.selectionStyle = .none cell.contentView.addSubview(messageStack) messageStack.autoPinWidthToSuperviewMargins() messageStack.autoPinHeightToSuperview(withMargin: 20) return cell }, actionBlock: { } )) return section } private func buildSenderSection() -> OWSTableSection { guard let incomingMessage = message as? TSIncomingMessage else { owsFailDebug("Unexpected message type") return OWSTableSection() } let section = OWSTableSection() section.headerTitle = NSLocalizedString( "MESSAGE_DETAILS_VIEW_SENT_FROM_TITLE", comment: "Title for the 'sent from' section on the 'message details' view." ) section.add(contactItem( for: incomingMessage.authorAddress, accessoryText: DateUtil.formatPastTimestampRelativeToNow(incomingMessage.timestamp), displayUDIndicator: incomingMessage.wasReceivedByUD )) return section } private func buildStatusSections() -> [OWSTableSection] { guard nil != message as? TSOutgoingMessage else { owsFailDebug("Unexpected message type") return [] } var sections = [OWSTableSection]() let orderedStatusGroups: [MessageReceiptStatus] = [ .viewed, .read, .delivered, .sent, .uploading, .sending, .pending, .failed, .skipped ] guard let messageRecipients = messageRecipients.get() else { return [] } for statusGroup in orderedStatusGroups { guard let recipients = messageRecipients[statusGroup], !recipients.isEmpty else { continue } let section = OWSTableSection() sections.append(section) let sectionTitle = self.sectionTitle(for: statusGroup) if let iconName = sectionIconName(for: statusGroup) { let headerView = UIView() headerView.layoutMargins = cellOuterInsetsWithMargin( top: (defaultSpacingBetweenSections ?? 0) + 12, left: Self.cellHInnerMargin * 0.5, bottom: 10, right: Self.cellHInnerMargin * 0.5 ) let label = UILabel() label.textColor = Theme.isDarkThemeEnabled ? UIColor.ows_gray05 : UIColor.ows_gray90 label.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold label.text = sectionTitle headerView.addSubview(label) label.autoPinHeightToSuperviewMargins() label.autoPinEdge(toSuperviewMargin: .leading) let iconView = UIImageView() iconView.contentMode = .scaleAspectFit iconView.setTemplateImageName( iconName, tintColor: Theme.isDarkThemeEnabled ? UIColor.ows_gray05 : UIColor.ows_gray90 ) headerView.addSubview(iconView) iconView.autoAlignAxis(.horizontal, toSameAxisOf: label) iconView.autoPinEdge(.leading, to: .trailing, of: label) iconView.autoPinEdge(toSuperviewMargin: .trailing) iconView.autoSetDimension(.height, toSize: 12) section.customHeaderView = headerView } else { section.headerTitle = sectionTitle } section.separatorInsetLeading = NSNumber(value: Float(Self.cellHInnerMargin + CGFloat(AvatarBuilder.smallAvatarSizePoints) + ContactCellView.avatarTextHSpacing)) for recipient in recipients { section.add(contactItem( for: recipient.address, accessoryText: recipient.accessoryText, displayUDIndicator: recipient.displayUDIndicator )) } } return sections } private func contactItem(for address: SignalServiceAddress, accessoryText: String, displayUDIndicator: Bool) -> OWSTableItem { return .init(customCellBlock: { [weak self] in guard let self = self else { return UITableViewCell() } let tableView = self.tableView guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier) as? ContactTableViewCell else { owsFailDebug("Missing cell.") return UITableViewCell() } Self.databaseStorage.read { transaction in let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .asUser) configuration.accessoryView = self.buildAccessoryView(text: accessoryText, displayUDIndicator: displayUDIndicator, transaction: transaction) cell.configure(configuration: configuration, transaction: transaction) } return cell }, actionBlock: { [weak self] in guard let self = self else { return } let actionSheet = MemberActionSheet(address: address, groupViewHelper: nil) actionSheet.present(from: self) } ) } private func buildAccessoryView(text: String, displayUDIndicator: Bool, transaction: SDSAnyReadTransaction) -> ContactCellAccessoryView { let label = CVLabel() label.textAlignment = .right let labelConfig = CVLabelConfig(text: text, font: .ows_dynamicTypeFootnoteClamped, textColor: Theme.ternaryTextColor) labelConfig.applyForRendering(label: label) let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: .greatestFiniteMagnitude) let shouldShowUD = preferences.shouldShowUnidentifiedDeliveryIndicators(transaction: transaction) guard displayUDIndicator && shouldShowUD else { return ContactCellAccessoryView(accessoryView: label, size: labelSize) } let imageView = CVImageView() imageView.setTemplateImageName(Theme.iconName(.sealedSenderIndicator), tintColor: Theme.ternaryTextColor) let imageSize = CGSize.square(20) let hStack = ManualStackView(name: "hStack") let hStackConfig = CVStackViewConfig(axis: .horizontal, alignment: .center, spacing: 8, layoutMargins: .zero) let hStackMeasurement = hStack.configure(config: hStackConfig, subviews: [imageView, label], subviewInfos: [ imageSize.asManualSubviewInfo(hasFixedSize: true), labelSize.asManualSubviewInfo ]) let hStackSize = hStackMeasurement.measuredSize return ContactCellAccessoryView(accessoryView: hStack, size: hStackSize) } private func buildValueLabel(name: String, value: String) -> UILabel { let label = UILabel() label.textColor = Theme.primaryTextColor label.font = .ows_dynamicTypeFootnoteClamped label.attributedText = .composed(of: [ name.styled(with: .font(UIFont.ows_dynamicTypeFootnoteClamped.ows_semibold)), " ", value ]) return label } // MARK: - Actions private func sectionIconName(for messageReceiptStatus: MessageReceiptStatus) -> String? { switch messageReceiptStatus { case .uploading, .sending, .pending: return "message_status_sending" case .sent: return "message_status_sent" case .delivered: return "message_status_delivered" case .read, .viewed: return "message_status_read" case .failed, .skipped: return nil } } private func sectionTitle(for messageReceiptStatus: MessageReceiptStatus) -> String { switch messageReceiptStatus { case .uploading: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING", comment: "Status label for messages which are uploading.") case .sending: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENDING", comment: "Status label for messages which are sending.") case .pending: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_PAUSED", comment: "Status label for messages which are paused.") case .sent: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENT", comment: "Status label for messages which are sent.") case .delivered: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_DELIVERED", comment: "Status label for messages which are delivered.") case .read: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_READ", comment: "Status label for messages which are read.") case .failed: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_FAILED", comment: "Status label for messages which are failed.") case .skipped: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SKIPPED", comment: "Status label for messages which were skipped.") case .viewed: return NSLocalizedString("MESSAGE_METADATA_VIEW_MESSAGE_STATUS_VIEWED", comment: "Status label for messages which are viewed.") } } private var isPanning = false @objc func handlePan(_ sender: UIPanGestureRecognizer) { var xOffset = sender.translation(in: view).x var xVelocity = sender.velocity(in: view).x if CurrentAppContext().isRTL { xOffset = -xOffset xVelocity = -xVelocity } if xOffset < 0 { xOffset = 0 } let percentage = xOffset / view.width switch sender.state { case .began: popPercentDrivenTransition = UIPercentDrivenInteractiveTransition() navigationController?.popViewController(animated: true) case .changed: popPercentDrivenTransition?.update(percentage) case .ended: let percentageThreshold: CGFloat = 0.5 let velocityThreshold: CGFloat = 500 let shouldFinish = (percentage >= percentageThreshold && xVelocity >= 0) || (xVelocity >= velocityThreshold) if shouldFinish { popPercentDrivenTransition?.finish() } else { popPercentDrivenTransition?.cancel() } popPercentDrivenTransition = nil case .cancelled, .failed: popPercentDrivenTransition?.cancel() popPercentDrivenTransition = nil case .possible: break @unknown default: break } } } // MARK: - extension MessageDetailViewController { @objc func didLongPressSent(sender: UIGestureRecognizer) { guard sender.state == .began else { return } let messageTimestamp = "\(message.timestamp)" UIPasteboard.general.string = messageTimestamp let toast = ToastController(text: NSLocalizedString( "MESSAGE_DETAIL_VIEW_DID_COPY_SENT_TIMESTAMP", comment: "Toast indicating that the user has copied the sent timestamp." )) toast.presentToastView(fromBottomOfView: view, inset: bottomLayoutGuide.length + 8) } } // MARK: - extension MessageDetailViewController: MediaGalleryDelegate { func mediaGallery(_ mediaGallery: MediaGallery, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject) { Logger.info("") guard items.contains(where: { $0.message == self.message }) else { Logger.info("ignoring deletion of unrelated media") return } self.wasDeleted = true } func mediaGallery(_ mediaGallery: MediaGallery, deletedSections: IndexSet, deletedItems: [IndexPath]) { guard self.wasDeleted else { return } self.dismiss(animated: true) { self.navigationController?.popViewController(animated: true) } } func mediaGallery(_ mediaGallery: MediaGallery, didReloadItemsInSections sections: IndexSet) { // No action needed } } // MARK: - extension MessageDetailViewController: ContactShareViewHelperDelegate { public func didCreateOrEditContact() { updateTableContents() self.dismiss(animated: true) } } extension MessageDetailViewController: LongTextViewDelegate { public func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) { self.detailDelegate?.detailViewMessageWasDeleted(self) } } extension MessageDetailViewController: MediaPresentationContextProvider { func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? { guard case let .gallery(galleryItem) = item else { owsFailDebug("Unexpected media type") return nil } guard let mediaView = cellView.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 better corner rounding. 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: - extension MediaPresentationContext { var mediaOverlayViews: [UIView] { guard let bodyMediaPresentationContext = mediaView.firstAncestor(ofType: BodyMediaPresentationContext.self) else { owsFailDebug("unexpected mediaView: \(mediaView)") return [] } return bodyMediaPresentationContext.mediaOverlayViews } } // MARK: - extension MessageDetailViewController: DatabaseChangeDelegate { public func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { AssertIsOnMainThread() guard databaseChanges.didUpdate(interaction: self.message) else { return } refreshContentForDatabaseUpdate() } public func databaseChangesDidUpdateExternally() { AssertIsOnMainThread() refreshContentForDatabaseUpdate() } public func databaseChangesDidReset() { AssertIsOnMainThread() refreshContentForDatabaseUpdate() } private func refreshContentForDatabaseUpdate() { guard databaseUpdateTimer == nil else { return } // Updating this view is slightly expensive and there will be tons of relevant // database updates when sending to a large group. Update latency isn't that // imporant, so we de-bounce to never update this view more than once every N seconds. self.databaseUpdateTimer = Timer.scheduledTimer( withTimeInterval: 2.0, repeats: false ) { [weak self] _ in guard let self = self else { return } assert(self.databaseUpdateTimer != nil) self.databaseUpdateTimer?.invalidate() self.databaseUpdateTimer = nil self.refreshContent() } } private func refreshContent() { AssertIsOnMainThread() guard !wasDeleted else { // Item was deleted in the tile view gallery. // Don't bother re-rendering, it will fail and we'll soon be dismissed. return } do { try databaseStorage.readThrows { transaction in let uniqueId = self.message.uniqueId guard let newMessage = TSInteraction.anyFetch(uniqueId: uniqueId, transaction: transaction) as? TSMessage else { Logger.error("Message was deleted") throw DetailViewError.messageWasDeleted } self.message = newMessage self.attachments = newMessage.mediaAttachments(with: transaction.unwrapGrdbRead) } guard let renderItem = buildRenderItem(interactionId: message.uniqueId) else { owsFailDebug("Could not build renderItem.") throw DetailViewError.messageWasDeleted } self.renderItem = renderItem if isIncoming { updateTableContents() } else { refreshMessageRecipientsAsync() } } catch DetailViewError.messageWasDeleted { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.detailDelegate?.detailViewMessageWasDeleted(self) } } catch { owsFailDebug("unexpected error: \(error)") } } private func refreshMessageRecipientsAsync() { guard let outgoingMessage = message as? TSOutgoingMessage else { return owsFailDebug("Unexpected message type") } DispatchQueue.sharedUserInitiated.async { [weak self] in guard let self = self else { return } let messageRecipientAddressesUnsorted = outgoingMessage.recipientAddresses() let messageRecipientAddressesSorted = self.databaseStorage.read { transaction in self.contactsManagerImpl.sortSignalServiceAddresses( messageRecipientAddressesUnsorted, transaction: transaction ) } let messageRecipientAddressesGrouped = messageRecipientAddressesSorted.reduce( into: [MessageReceiptStatus: [MessageRecipientModel]]() ) { result, address in guard let recipientState = outgoingMessage.recipientState(for: address) else { return owsFailDebug("no message status for recipient: \(address).") } let (status, statusMessage, _) = MessageRecipientStatusUtils.recipientStatusAndStatusMessage( outgoingMessage: outgoingMessage, recipientState: recipientState ) var bucket = result[status] ?? [] switch status { case .delivered, .read, .sent, .viewed: bucket.append(MessageRecipientModel( address: address, accessoryText: statusMessage, displayUDIndicator: recipientState.wasSentByUD )) case .sending, .failed, .skipped, .uploading, .pending: bucket.append(MessageRecipientModel( address: address, accessoryText: "", displayUDIndicator: false )) } result[status] = bucket } self.messageRecipients.set(messageRecipientAddressesGrouped) DispatchQueue.main.async { self.updateTableContents() } } } } // MARK: - extension MessageDetailViewController: CVComponentDelegate { // MARK: - Body Text Items func cvc_didTapBodyTextItem(_ item: CVBodyTextLabel.ItemObject) {} func cvc_didLongPressBodyTextItem(_ item: CVBodyTextLabel.ItemObject) {} // MARK: - Long Press // TODO: func cvc_didLongPressTextViewItem(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {} // TODO: func cvc_didLongPressMediaViewItem(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {} // TODO: func cvc_didLongPressQuote(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {} // TODO: func cvc_didLongPressSystemMessage(_ cell: CVCell, itemViewModel: CVItemViewModelImpl) {} // TODO: func cvc_didLongPressSticker(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {} // TODO: func cvc_didChangeLongpress(_ itemViewModel: CVItemViewModelImpl) {} // TODO: func cvc_didEndLongpress(_ itemViewModel: CVItemViewModelImpl) {} // TODO: func cvc_didCancelLongpress(_ itemViewModel: CVItemViewModelImpl) {} // MARK: - // TODO: func cvc_didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {} // TODO: func cvc_didTapSenderAvatar(_ interaction: TSInteraction) {} // TODO: func cvc_shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool { false } // TODO: func cvc_didTapReactions(reactionState: InteractionReactionState, message: TSMessage) {} func cvc_didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) { AssertIsOnMainThread() let viewController = LongTextViewController(itemViewModel: itemViewModel) viewController.delegate = self navigationController?.pushViewController(viewController, animated: true) } // TODO: var cvc_hasPendingMessageRequest: Bool { false } func cvc_didTapFailedOrPendingDownloads(_ message: TSMessage) {} // MARK: - Messages func cvc_didTapBodyMedia(itemViewModel: CVItemViewModelImpl, attachmentStream: TSAttachmentStream, imageView: UIView) { guard let thread = thread else { owsFailDebug("Missing thread.") return } let mediaPageVC = MediaPageViewController( initialMediaAttachment: attachmentStream, thread: thread, showingSingleMessage: true ) mediaPageVC.mediaGallery.addDelegate(self) present(mediaPageVC, animated: true) } func cvc_didTapGenericAttachment(_ attachment: CVComponentGenericAttachment) -> CVAttachmentTapAction { if attachment.canQuickLook { let previewController = QLPreviewController() previewController.dataSource = attachment present(previewController, animated: true) return .handledByDelegate } else { return .default } } func cvc_didTapQuotedReply(_ quotedReply: OWSQuotedReplyModel) {} func cvc_didTapLinkPreview(_ linkPreview: OWSLinkPreview) { guard let urlString = linkPreview.urlString else { owsFailDebug("Missing url.") return } guard let url = URL(string: urlString) else { owsFailDebug("Invalid url: \(urlString).") return } UIApplication.shared.open(url, options: [:]) } func cvc_didTapContactShare(_ contactShare: ContactShareViewModel) { let contactViewController = ContactViewController(contactShare: contactShare) self.navigationController?.pushViewController(contactViewController, animated: true) } func cvc_didTapSendMessage(toContactShare contactShare: ContactShareViewModel) { contactShareViewHelper.sendMessage(contactShare: contactShare, fromViewController: self) } func cvc_didTapSendInvite(toContactShare contactShare: ContactShareViewModel) { contactShareViewHelper.showInviteContact(contactShare: contactShare, fromViewController: self) } func cvc_didTapAddToContacts(contactShare: ContactShareViewModel) { contactShareViewHelper.showAddToContacts(contactShare: contactShare, fromViewController: self) } func cvc_didTapStickerPack(_ stickerPackInfo: StickerPackInfo) { let packView = StickerPackViewController(stickerPackInfo: stickerPackInfo) packView.present(from: self, animated: true) } func cvc_didTapGroupInviteLink(url: URL) { GroupInviteLinksUI.openGroupInviteLink(url, fromViewController: self) } func cvc_didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {} func cvc_prepareMessageDetailForInteractivePresentation(_ itemViewModel: CVItemViewModelImpl) {} func cvc_beginCellAnimation(maximumDuration: TimeInterval) -> EndCellAnimation { return {} } var isConversationPreview: Bool { true } var wallpaperBlurProvider: WallpaperBlurProvider? { nil } // MARK: - Selection public var selectionState: CVSelectionState { CVSelectionState() } // MARK: - System Cell // TODO: func cvc_didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {} // TODO: func cvc_didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {} // TODO: func cvc_didTapInvalidIdentityKeyErrorMessage(_ message: TSInvalidIdentityKeyErrorMessage) {} // TODO: func cvc_didTapCorruptedMessage(_ message: TSErrorMessage) {} // TODO: func cvc_didTapSessionRefreshMessage(_ message: TSErrorMessage) {} // See: resendGroupUpdate // TODO: func cvc_didTapResendGroupUpdateForErrorMessage(_ errorMessage: TSErrorMessage) {} // TODO: func cvc_didTapShowFingerprint(_ address: SignalServiceAddress) {} // TODO: func cvc_didTapIndividualCall(_ call: TSCall) {} // TODO: func cvc_didTapGroupCall() {} // TODO: func cvc_didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {} // TODO: func cvc_didTapFailedOutgoingMessage(_ message: TSOutgoingMessage) {} // TODO: func cvc_didTapShowGroupMigrationLearnMoreActionSheet(infoMessage: TSInfoMessage, oldGroupModel: TSGroupModel, newGroupModel: TSGroupModel) {} func cvc_didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {} func cvc_didTapViewGroupDescription(groupModel: TSGroupModel?) {} // TODO: func cvc_didTapShowConversationSettings() {} // TODO: func cvc_didTapShowConversationSettingsAndShowMemberRequests() {} // TODO: func cvc_didTapShowUpgradeAppUI() {} // TODO: func cvc_didTapUpdateSystemContact(_ address: SignalServiceAddress, newNameComponents: PersonNameComponents) {} func cvc_didTapViewOnceAttachment(_ interaction: TSInteraction) { guard let renderItem = renderItem else { owsFailDebug("Missing renderItem.") return } let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) ViewOnceMessageViewController.tryToPresent(interaction: itemViewModel.interaction, from: self) } // TODO: func cvc_didTapViewOnceExpired(_ interaction: TSInteraction) {} // TODO: func cvc_didTapUnknownThreadWarningGroup() {} // TODO: func cvc_didTapUnknownThreadWarningContact() {} func cvc_didTapDeliveryIssueWarning(_ message: TSErrorMessage) {} } extension MessageDetailViewController: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return (animationController as? AnimationController)?.percentDrivenTransition } func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { let animationController = AnimationController(operation: operation) if operation == .push { animationController.percentDrivenTransition = pushPercentDrivenTransition } if operation == .pop { animationController.percentDrivenTransition = popPercentDrivenTransition } return animationController } } private class AnimationController: NSObject, UIViewControllerAnimatedTransitioning { weak var percentDrivenTransition: UIPercentDrivenInteractiveTransition? let operation: UINavigationController.Operation required init(operation: UINavigationController.Operation) { self.operation = operation super.init() } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 0.35 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromView = transitionContext.view(forKey: .from), let toView = transitionContext.view(forKey: .to) else { owsFailDebug("Missing view controllers.") return transitionContext.completeTransition(false) } let containerView = transitionContext.containerView let directionMultiplier: CGFloat = CurrentAppContext().isRTL ? -1 : 1 let bottomViewHiddenTransform = CGAffineTransform(translationX: (fromView.width / 3) * directionMultiplier, y: 0) let topViewHiddenTransform = CGAffineTransform(translationX: -fromView.width * directionMultiplier, y: 0) let bottomViewOverlay = UIView() bottomViewOverlay.backgroundColor = .ows_blackAlpha10 let topView: UIView let bottomView: UIView let isPushing = operation == .push if isPushing { topView = fromView bottomView = toView bottomView.transform = bottomViewHiddenTransform bottomViewOverlay.alpha = 1 } else { topView = toView bottomView = fromView topView.transform = topViewHiddenTransform bottomViewOverlay.alpha = 0 } containerView.addSubview(bottomView) containerView.addSubview(topView) bottomView.addSubview(bottomViewOverlay) bottomViewOverlay.frame = bottomView.bounds let animationOptions: UIView.AnimationOptions if percentDrivenTransition != nil { animationOptions = .curveLinear } else { animationOptions = .curveEaseInOut } UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: animationOptions) { if isPushing { topView.transform = topViewHiddenTransform bottomView.transform = .identity bottomViewOverlay.alpha = 0 } else { topView.transform = .identity bottomView.transform = bottomViewHiddenTransform bottomViewOverlay.alpha = 1 } } completion: { _ in bottomView.transform = .identity topView.transform = .identity bottomViewOverlay.removeFromSuperview() if transitionContext.transitionWasCancelled { toView.removeFromSuperview() } else { fromView.removeFromSuperview() // When completing the transition, the first responder chain gets // messed with. We don't want the keyboard to present when returning // from message details, so we dismiss it when we leave the view. if let fromViewController = transitionContext.viewController(forKey: .from) as? ConversationViewController { fromViewController.dismissKeyBoard() } } transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } } }