1133 lines
42 KiB
Swift
1133 lines
42 KiB
Swift
//
|
|
// 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.build(address: address,
|
|
localUserDisplayMode: .asUser,
|
|
transaction: transaction)
|
|
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)
|
|
}
|
|
}
|
|
}
|