Path: blob/main/Signal/src/ViewControllers/MessageDetailViewController.swift
1 views
//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import QuickLook
import SignalServiceKit
import SignalUI
protocol MessageDetailViewDelegate: MessageEditHistoryViewDelegate {
func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController)
}
class MessageDetailViewController: OWSTableViewController2 {
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 let threadViewModel: ThreadViewModel
let spoilerState: SpoilerRenderState
private let editManager: EditManager
private var wasDeleted: Bool = false
private var isIncoming: Bool { message is TSIncomingMessage }
private var expires: Bool { message.expiresInSeconds > 0 }
private struct MessageRecipientModel {
let address: SignalServiceAddress
let accessoryText: String
let displayUDIndicator: Bool
}
private let messageRecipients = AtomicOptional<[MessageReceiptStatus: [MessageRecipientModel]]>(nil, lock: .sharedGlobal)
private let cellView = CVCellView()
private var bodyMediaAttachments: [ReferencedAttachment]?
private var bodyMediaAttachmentStreams: [ReferencedAttachmentStream]? {
return bodyMediaAttachments?.compactMap { $0.asReferencedStream }
}
private let byteCountFormatter: ByteCountFormatter = ByteCountFormatter()
private lazy var contactShareViewHelper: ContactShareViewHelper = {
let contactShareViewHelper = ContactShareViewHelper()
contactShareViewHelper.delegate = self
return contactShareViewHelper
}()
private var databaseUpdateTimer: Timer?
private var expiryLabelTimer: Timer?
private var expiryLabelName: String {
OWSLocalizedString(
"MESSAGE_METADATA_VIEW_DISAPPEARS_IN",
comment: "Label for the 'disappears' field of the 'message metadata' view.",
)
}
private lazy var expirationLabelFormatter: DateComponentsFormatter = {
let expirationLabelFormatter = DateComponentsFormatter()
expirationLabelFormatter.unitsStyle = .full
expirationLabelFormatter.allowedUnits = [.weekOfMonth, .day, .hour, .minute, .second]
expirationLabelFormatter.maximumUnitCount = 2
return expirationLabelFormatter
}()
private var expiryLabelValue: String {
let expiresAt = message.expiresAt
guard expiresAt > 0 else {
owsFailDebug("We should never hit this code, because we should never show the label")
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_NEVER_DISAPPEARS",
comment: "On the 'message metadata' view, if a message never disappears, this text is shown as a fallback.",
)
}
let now = Date()
let expiresAtDate = Date(millisecondsSince1970: expiresAt)
let result: String?
if expiresAtDate >= now {
result = expirationLabelFormatter.string(from: now, to: expiresAtDate)
} else {
// This is unusual, but could happen if you change your device clock.
result = expirationLabelFormatter.string(from: 0)
}
guard let result else {
owsFailDebug("Could not format duration")
return ""
}
return result
}
private var expiryLabelAttributedText: NSAttributedString {
Self.valueLabelAttributedText(name: expiryLabelName, value: expiryLabelValue)
}
private var expiryLabel: UILabel?
// MARK: Initializers
init(
message: TSMessage,
threadViewModel: ThreadViewModel,
spoilerState: SpoilerRenderState,
editManager: EditManager,
thread: TSThread,
) {
self.message = message
self.threadViewModel = threadViewModel
self.spoilerState = spoilerState
self.editManager = editManager
super.init()
}
// MARK: De-initializers
deinit {
expiryLabelTimer?.invalidate()
}
// MARK: View Lifecycle
override func themeDidChange() {
super.themeDidChange()
refreshContent()
}
override func viewDidLoad() {
super.viewDidLoad()
title = OWSLocalizedString(
"MESSAGE_METADATA_VIEW_TITLE",
comment: "Title for the 'message metadata' view.",
)
DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
startExpiryLabelTimerIfNecessary()
// 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
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 startExpiryLabelTimerIfNecessary() {
guard message.expiresAt > 0 else { return }
guard expiryLabelTimer == nil else { return }
expiryLabelTimer = Timer.scheduledTimer(
withTimeInterval: 1,
repeats: true,
) { [weak self] _ in
self?.updateExpiryLabel()
}
}
private func updateTableContents() {
let contents = OWSTableContents()
contents.add(buildMessageSection())
if
!message.wasRemotelyDeleted,
let editHistorySection = buildEditHistorySection()
{
contents.add(editHistorySection)
}
if isIncoming {
contents.add(buildSenderSection())
} else {
contents.add(sections: buildStatusSections())
}
self.contents = contents
}
private func buildRenderItem(
message interaction: TSMessage,
spoilerState: SpoilerRenderState,
transaction: DBReadTransaction,
) -> CVRenderItem? {
guard
let thread = TSThread.fetchViaCache(
uniqueId: interaction.uniqueThreadId,
transaction: transaction,
)
else {
owsFailDebug("Missing thread.")
return nil
}
let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: thread, transaction: transaction)
let conversationStyle = ConversationStyle(
type: .messageDetails,
thread: thread,
viewWidth: view.width - (cellOuterInsets.totalWidth + (Self.cellHInnerMargin * 2)),
hasWallpaper: false,
shouldDimWallpaperInDarkMode: false,
isWallpaperPhoto: false,
chatColor: DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: transaction,
),
)
let groupNameColors = GroupNameColors.forThread(thread)
return CVLoader.buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
conversationStyle: conversationStyle,
spoilerState: spoilerState,
groupNameColors: groupNameColors,
transaction: transaction,
)
}
private func buildMessageSection() -> OWSTableSection {
guard let 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.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapCell)))
cellContainer.addSubview(cellView)
cellView.autoPinHeightToSuperviewMargins()
cellView.autoPinEdge(toSuperviewEdge: .leading)
cellView.autoPinEdge(toSuperviewEdge: .trailing)
messageStack.addArrangedSubview(cellContainer)
// Sent time
let sentTimeLabel = Self.buildValueLabel(
name: OWSLocalizedString(
"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(Self.buildValueLabel(
name: OWSLocalizedString(
"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 expires {
let expiryLabel = Self.buildValueLabel(name: expiryLabelName, value: expiryLabelValue)
messageStack.addArrangedSubview(expiryLabel)
self.expiryLabel = expiryLabel
}
if bodyMediaAttachments?.count == 1, let attachment = bodyMediaAttachments?.first {
if let sourceFilename = attachment.reference.sourceFilename {
messageStack.addArrangedSubview(Self.buildValueLabel(
name: OWSLocalizedString(
"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.attachment.asStream()?.unencryptedByteCount ?? 0) {
messageStack.addArrangedSubview(Self.buildValueLabel(
name: OWSLocalizedString(
"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 mimeType = attachment.attachment.mimeType
messageStack.addArrangedSubview(Self.buildValueLabel(
name: OWSLocalizedString(
"MESSAGE_METADATA_VIEW_ATTACHMENT_MIME_TYPE",
comment: "Label for the MIME type of attachments in the 'message metadata' view.",
),
value: mimeType,
))
}
}
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
}
@objc
private func didTapCell(_ sender: UITapGestureRecognizer) {
// For now, only allow tapping on audio cells. The full gamut of cell types
// might result in unexpected behaviors if made tappable from the detail view.
guard renderItem?.componentState.audioAttachment != nil else {
return
}
_ = cellView.handleTap(sender: sender, componentDelegate: self)
}
private func buildSenderSection() -> OWSTableSection {
guard let incomingMessage = message as? TSIncomingMessage else {
owsFailDebug("Unexpected message type")
return OWSTableSection()
}
let section = OWSTableSection()
section.headerTitle = OWSLocalizedString(
"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 buildEditHistorySection() -> OWSTableSection? {
if case .none = message.editState {
return nil
}
let section = OWSTableSection()
section.add(.disclosureItem(
icon: .buttonEdit,
withText: OWSLocalizedString(
"MESSAGE_DETAILS_EDIT_HISTORY_TITLE",
comment: "Title for the 'edit history' section on the 'message details' view.",
),
actionBlock: { [weak self] in
guard let self else { return }
let sheet = EditHistoryTableSheetViewController(
message: self.message,
threadViewModel: self.threadViewModel,
spoilerState: self.spoilerState,
editManager: self.editManager,
database: SSKEnvironment.shared.databaseStorageRef,
databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
)
sheet.delegate = self.detailDelegate
self.present(sheet, animated: true)
},
))
return section
}
private func buildStatusSections() -> [OWSTableSection] {
guard message is 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 = .init(
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.dynamicTypeHeadlineClamped
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 = 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 else { return UITableViewCell() }
let tableView = self.tableView
guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier) as? ContactTableViewCell else {
owsFailDebug("Missing cell.")
return UITableViewCell()
}
SSKEnvironment.shared.databaseStorageRef.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 else { return }
ProfileSheetSheetCoordinator(
address: address,
groupViewHelper: nil,
spoilerState: self.spoilerState,
)
.presentAppropriateSheet(from: self)
},
)
}
private func updateExpiryLabel() {
expiryLabel?.attributedText = expiryLabelAttributedText
}
private func buildAccessoryView(
text: String,
displayUDIndicator: Bool,
transaction: DBReadTransaction,
) -> ContactCellAccessoryView {
let label = CVLabel()
label.textAlignment = .right
let labelConfig = CVLabelConfig.unstyledText(
text,
font: .dynamicTypeFootnoteClamped,
textColor: .Signal.tertiaryLabel,
)
labelConfig.applyForRendering(label: label)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: .greatestFiniteMagnitude)
let shouldShowUD = SSKEnvironment.shared.preferencesRef.shouldShowUnidentifiedDeliveryIndicators(transaction: transaction)
guard displayUDIndicator, shouldShowUD else {
return ContactCellAccessoryView(accessoryView: label, size: labelSize)
}
let imageView = CVImageView()
imageView.setTemplateImageName(Theme.iconName(.sealedSenderIndicator), tintColor: .Signal.tertiaryLabel)
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 static func valueLabelAttributedText(name: String, value: String) -> NSAttributedString {
.composed(of: [
name.styled(with: .font(UIFont.dynamicTypeFootnoteClamped.semibold())),
" ",
value,
])
}
private static func buildValueLabel(name: String, value: String) -> UILabel {
let label = UILabel()
label.textColor = Theme.primaryTextColor
label.font = .dynamicTypeFootnoteClamped
label.attributedText = valueLabelAttributedText(name: name, value: 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 OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_UPLOADING",
comment: "Status label for messages which are uploading.",
)
case .sending:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENDING",
comment: "Status label for messages which are sending.",
)
case .pending:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_PAUSED",
comment: "Status label for messages which are paused.",
)
case .sent:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SENT",
comment: "Status label for messages which are sent.",
)
case .delivered:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_DELIVERED",
comment: "Status label for messages which are delivered.",
)
case .read:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_READ",
comment: "Status label for messages which are read.",
)
case .failed:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_FAILED",
comment: "Status label for messages which are failed.",
)
case .skipped:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_SKIPPED",
comment: "Status label for messages which were skipped.",
)
case .viewed:
return OWSLocalizedString(
"MESSAGE_METADATA_VIEW_MESSAGE_STATUS_VIEWED",
comment: "Status label for messages which are viewed.",
)
}
}
private var isPanning = false
@objc
private 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
private func didLongPressSent(sender: UIGestureRecognizer) {
guard sender.state == .began else {
return
}
let messageTimestamp = "\(message.timestamp)"
UIPasteboard.general.string = messageTimestamp
let toast = ToastController(
text: OWSLocalizedString(
"MESSAGE_DETAIL_VIEW_DID_COPY_SENT_TIMESTAMP",
comment: "Toast indicating that the user has copied the sent timestamp.",
),
image: .copy,
)
toast.presentToastView(from: .bottom, of: view, inset: view.safeAreaInsets.bottom + 8)
}
}
// MARK: -
extension MessageDetailViewController: MediaGalleryDelegate {
func mediaGallery(_ mediaGallery: MediaGallery, sectionsDidChange journal: MediaGallery.Journal) {
Logger.info("")
}
func mediaGallery(_ mediaGallery: MediaGallery, applyUpdate update: MediaGallery.Update) {
Logger.debug("")
}
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 mediaGalleryDidDeleteItem(_ mediaGallery: MediaGallery) {
guard self.wasDeleted else {
return
}
self.dismiss(animated: true) {
self.navigationController?.popViewController(animated: true)
}
}
func mediaGalleryDidReloadItems(_ mediaGallery: MediaGallery) {
self.didReloadAllSectionsInMediaGallery(mediaGallery)
}
func didAddSectionInMediaGallery(_ mediaGallery: MediaGallery) {
// Does not affect the current item.
}
func didReloadAllSectionsInMediaGallery(_ mediaGallery: MediaGallery) {
if
let firstAttachment = self.bodyMediaAttachments?.first,
mediaGallery.ensureLoadedForDetailView(focusedAttachment: firstAttachment) == nil
{
// Assume the item was deleted.
self.dismiss(animated: true) {
self.navigationController?.popViewController(animated: true)
}
}
}
func mediaGalleryShouldDeferUpdate(_ mediaGallery: MediaGallery) -> Bool {
return false
}
}
// MARK: -
extension MessageDetailViewController: ContactShareViewHelperDelegate {
func didCreateOrEditContact() {
updateTableContents()
}
}
extension MessageDetailViewController: LongTextViewDelegate {
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)
return MediaPresentationContext(
mediaView: mediaView,
presentationFrame: presentationFrame,
mediaViewShape: .rectangle(CVComponentMessage.bubbleWideCornerRadius),
)
}
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: -
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 {
func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
guard databaseChanges.didUpdate(interaction: self.message) else {
return
}
refreshContentForDatabaseUpdate()
}
func databaseChangesDidUpdateExternally() {
refreshContentForDatabaseUpdate()
}
func databaseChangesDidReset() {
refreshContentForDatabaseUpdate()
}
/// ForceImmediately should only be used based on user input, since it ignores any debouncing
/// and makes an update happen right away (killing any scheduled/debounced updates)
private func refreshContentForDatabaseUpdate(forceImmediately: Bool = false) {
// 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.
let updateBlock = { [weak self] in
guard let self else {
return
}
self.databaseUpdateTimer?.invalidate()
self.databaseUpdateTimer = nil
self.refreshContent()
}
if forceImmediately {
updateBlock()
return
}
guard databaseUpdateTimer == nil else { return }
self.databaseUpdateTimer = Timer.scheduledTimer(
withTimeInterval: 2.0,
repeats: false,
) { _ in
assert(self.databaseUpdateTimer != nil)
updateBlock()
}
}
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
}
let messageStillExists = SSKEnvironment.shared.databaseStorageRef.read { transaction in
let uniqueId = message.uniqueId
guard let newMessage = TSInteraction.fetchViaCache(uniqueId: uniqueId, transaction: transaction) as? TSMessage else {
return false
}
self.message = newMessage
self.bodyMediaAttachments = DependenciesBridge.shared.attachmentStore
.fetchReferencedAttachments(
for: .messageBodyAttachment(messageRowId: newMessage.sqliteRowId!),
tx: transaction,
)
guard
let renderItem = buildRenderItem(
message: newMessage,
spoilerState: spoilerState,
transaction: transaction,
)
else {
return false
}
self.renderItem = renderItem
return true
}
guard messageStillExists else {
Logger.error("Message was deleted")
DispatchQueue.main.async {
self.detailDelegate?.detailViewMessageWasDeleted(self)
}
return
}
if isIncoming {
updateTableContents()
} else {
refreshMessageRecipientsAsync()
}
}
private func refreshMessageRecipientsAsync() {
guard let outgoingMessage = message as? TSOutgoingMessage else {
return owsFailDebug("Unexpected message type")
}
DispatchQueue.sharedUserInitiated.async { [weak self] in
guard let self else { return }
let messageRecipientAddressesUnsorted = outgoingMessage.recipientAddresses()
let (hasBodyAttachments, messageRecipientAddressesSorted) = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return (
outgoingMessage.hasBodyAttachments(transaction: transaction),
SSKEnvironment.shared.contactManagerImplRef.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,
hasBodyAttachments: hasBodyAttachments,
)
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 {
func didTapShowEditHistory(_ itemViewModel: CVItemViewModelImpl) {
owsFailDebug("Taps are not supported in message details.")
}
func enqueueReload() {
self.refreshContent()
}
func enqueueReloadWithoutCaches() {
self.refreshContentForDatabaseUpdate(forceImmediately: true)
}
// MARK: - Body Text Items
func didTapBodyTextItem(_ item: CVTextLabel.Item) {}
func didLongPressBodyTextItem(_ item: CVTextLabel.Item) {}
// MARK: - System Message Items
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
// MARK: - Double-Tap
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
// MARK: - Long Press
// TODO:
func didLongPressTextViewItem(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
// TODO:
func didLongPressMediaViewItem(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
// TODO:
func didLongPressQuote(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
// TODO:
func didLongPressSystemMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
) {}
// TODO:
func didLongPressSticker(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressPaymentMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) { }
func didLongPressPoll(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
// TODO:
func didChangeLongPress(_ itemViewModel: CVItemViewModelImpl) {}
// TODO:
func didEndLongPress(_ itemViewModel: CVItemViewModelImpl) {}
// TODO:
func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl) {}
// MARK: -
func willBecomeVisibleWithSkippedDownloads(_ message: TSMessage) {}
func didTapSkippedDownloads(_ message: TSMessage) {}
func didCancelDownload(_ message: TSMessage, attachmentId: Attachment.IDType) {}
// MARK: -
// TODO:
func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {}
// TODO:
func didTapSenderAvatar(_ interaction: TSInteraction) {}
// TODO:
func shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool { false }
// TODO:
func didTapReactions(
reactionState: InteractionReactionState,
message: TSMessage,
) {}
func didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) {}
// TODO:
var hasPendingMessageRequest: Bool { false }
func didTapUndownloadableMedia() {}
func didTapUndownloadableGenericFile() {}
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
// MARK: - Messages
func didTapBodyMedia(
itemViewModel: CVItemViewModelImpl,
attachmentStream: ReferencedAttachmentStream,
imageView: UIView,
) {
guard let thread else {
owsFailDebug("Missing thread.")
return
}
guard
let mediaPageVC = MediaPageViewController(
initialMediaAttachment: attachmentStream,
thread: thread,
spoilerState: self.spoilerState,
showingSingleMessage: true,
)
else {
return
}
mediaPageVC.mediaGallery.addDelegate(self)
present(mediaPageVC, animated: true)
}
func didTapGenericAttachment(_ attachment: CVComponentGenericAttachment) -> CVAttachmentTapAction {
if let previewController = attachment.createQLPreviewController() {
present(previewController, animated: true)
return .handledByDelegate
} else {
return .default
}
}
func didTapQuotedReply(_ quotedReply: QuotedReplyModel) {}
func didTapLinkPreview(url: URL) {
UIApplication.shared.open(url, options: [:])
}
func didTapContactShare(_ contactShare: ContactShareViewModel) {
let contactViewController = ContactViewController(contactShare: contactShare)
self.navigationController?.pushViewController(contactViewController, animated: true)
}
func didTapSendMessage(to phoneNumbers: [String]) {
contactShareViewHelper.sendMessage(to: phoneNumbers, from: self)
}
func didTapSendInvite(toContactShare contactShare: ContactShareViewModel) {
contactShareViewHelper.showInviteContact(contactShare: contactShare, from: self)
}
func didTapAddToContacts(contactShare: ContactShareViewModel) {
contactShareViewHelper.showAddToContactsPrompt(contactShare: contactShare, from: self)
}
func didTapStickerPack(_ stickerPackInfo: StickerPackInfo) {
let packView = StickerPackViewController(stickerPackInfo: stickerPackInfo)
packView.present(from: self, animated: true)
}
func didTapPayment(_ payment: PaymentsHistoryItem) {}
func didTapGroupInviteLink(url: URL) {
GroupInviteLinksUI.openGroupInviteLink(url, fromViewController: self)
}
func didTapProxyLink(url: URL) {}
func didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {}
// Never wrap gifts on the message details screen
func willWrapGift(_ messageUniqueId: String) -> Bool { false }
func willShakeGift(_ messageUniqueId: String) -> Bool { false }
func willUnwrapGift(_ itemViewModel: CVItemViewModelImpl) {}
func didTapGiftBadge(_ itemViewModel: CVItemViewModelImpl, profileBadge: ProfileBadge, isExpired: Bool, isRedeemed: Bool) {}
func prepareMessageDetailForInteractivePresentation(_ itemViewModel: CVItemViewModelImpl) {}
func beginCellAnimation(maximumDuration: TimeInterval) -> EndCellAnimation {
return {}
}
var wallpaperBlurProvider: WallpaperBlurProvider? { nil }
// MARK: - Selection
var selectionState: CVSelectionState { CVSelectionState() }
// MARK: - System Cell
// TODO:
func didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {}
// TODO:
func didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {}
// TODO:
func didTapCorruptedMessage(_ message: TSErrorMessage) {}
// TODO:
func didTapSessionRefreshMessage(_ message: TSErrorMessage) {}
// See: resendGroupUpdate
// TODO:
func didTapResendGroupUpdateForErrorMessage(_ errorMessage: TSErrorMessage) {}
// TODO:
func didTapShowFingerprint(_ address: SignalServiceAddress) {}
// TODO:
func didTapIndividualCall(_ call: TSCall) {}
// TODO:
func didTapLearnMoreMissedCallFromBlockedContact(_ call: TSCall) {}
// TODO:
func didTapGroupCall() {}
// TODO:
func didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {}
// TODO:
func didTapFailedMessage(_ message: TSMessage) {}
// TODO:
func didTapGroupMigrationLearnMore() {}
func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {}
func didTapViewGroupDescription(newGroupDescription: String) {}
func didTapNameEducation(type: SafetyTipsType) {}
// TODO:
func didTapShowConversationSettings() {}
// TODO:
func didTapShowConversationSettingsAndShowMemberRequests() {}
// TODO:
func didTapBlockRequest(
groupModel: TSGroupModelV2,
requesterName: String,
requesterAci: Aci,
) {}
// TODO:
func didTapShowUpgradeAppUI() {}
// TODO:
func didTapUpdateSystemContact(
_ address: SignalServiceAddress,
newNameComponents: PersonNameComponents,
) {}
// TODO:
func didTapPhoneNumberChange(aci: Aci, phoneNumberOld: String, phoneNumberNew: String) {}
func didTapViewOnceAttachment(_ interaction: TSInteraction) {
guard let renderItem else {
owsFailDebug("Missing renderItem.")
return
}
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
ViewOnceMessageViewController.tryToPresent(
interaction: itemViewModel.interaction,
from: self,
)
}
// TODO:
func didTapViewOnceExpired(_ interaction: TSInteraction) {}
func didTapContactName(thread: TSContactThread) {}
// TODO:
func didTapUnknownThreadWarningGroup() {}
// TODO:
func didTapUnknownThreadWarningContact() {}
func didTapDeliveryIssueWarning(_ message: TSErrorMessage) {}
func didTapActivatePayments() {}
func didTapSendPayment() {}
func didTapThreadMergeLearnMore(phoneNumber: String) {}
func didTapReportSpamLearnMore() {}
func didTapMessageRequestAcceptedOptions() {}
func didTapJoinCallLinkCall(callLink: CallLink) {}
func didTapViewVotes(poll: OWSPoll) {}
func didTapViewPoll(pollInteractionUniqueId: String) {}
func didTapVoteOnPoll(poll: OWSPoll, optionIndex: UInt32, isUnvote: Bool) {}
func didTapViewPinnedMessage(pinnedMessageUniqueId: String) {}
func didTapSafetyTips() {}
}
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
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 animations: () -> Void = {
if isPushing {
topView.transform = topViewHiddenTransform
bottomView.transform = .identity
bottomViewOverlay.alpha = 0
} else {
topView.transform = .identity
bottomView.transform = bottomViewHiddenTransform
bottomViewOverlay.alpha = 1
}
}
let completion: () -> Void = {
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)
}
let isInteractive = percentDrivenTransition != nil
if #available(iOS 18.0, *), !isInteractive {
UIView.animate(
.spring(duration: transitionDuration(using: transitionContext)),
changes: animations,
completion: completion,
)
} else {
UIView.animate(
withDuration: transitionDuration(using: transitionContext),
delay: 0,
options: isInteractive ? .curveLinear : .curveEaseInOut,
animations: animations,
) { _ in
completion()
}
}
}
}