Path: blob/main/Signal/src/ViewControllers/PinnedMessages/PinnedMessagesDetailsViewController.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalServiceKit
import SignalUI
class PinnedMessagesDetailsViewController: OWSViewController, DatabaseChangeDelegate, PinnedMessageLongPressDelegate.ActionDelegate {
private var pinnedMessages: [TSMessage]
private let threadViewModel: ThreadViewModel
private let db: DB
private var messageLongPressDelegates: [PinnedMessageLongPressDelegate] = []
private var pinnedMessageManager: PinnedMessageManager
let spoilerState: SpoilerRenderState
private weak var delegate: PinnedMessageInteractionManagerDelegate?
private var lastKnownWidth: CGFloat = 0
static let goToMessageButtonSize: CGFloat = 36
init(
pinnedMessages: [TSMessage],
threadViewModel: ThreadViewModel,
database: DB,
delegate: PinnedMessageInteractionManagerDelegate,
databaseChangeObserver: DatabaseChangeObserver,
pinnedMessageManager: PinnedMessageManager,
spoilerState: SpoilerRenderState,
) {
self.pinnedMessages = pinnedMessages
self.threadViewModel = threadViewModel
self.db = database
self.delegate = delegate
self.pinnedMessageManager = pinnedMessageManager
self.spoilerState = spoilerState
super.init()
view.backgroundColor = .Signal.groupedBackground
databaseChangeObserver.appendDatabaseChangeDelegate(self)
let titleLabel = UILabel()
titleLabel.text = OWSLocalizedString(
"PINNED_MESSAGES_DETAILS_TITLE",
comment: "Title for Pinned Messages detail view",
)
titleLabel.font = .dynamicTypeHeadlineClamped.semibold()
titleLabel.textColor = UIColor.Signal.label
titleLabel.textAlignment = .center
let subtitleLabel = UILabel()
subtitleLabel.text = threadViewModel.name
subtitleLabel.font = .dynamicTypeSubheadlineClamped
subtitleLabel.textColor = UIColor.Signal.secondaryLabel
subtitleLabel.textAlignment = .center
let titleStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
titleStackView.axis = .vertical
titleStackView.alignment = .center
titleStackView.spacing = 4
navigationItem.titleView = titleStackView
navigationItem.rightBarButtonItem = .doneButton(dismissingFrom: self)
}
private func layoutPinnedMessages(tx: DBReadTransaction) {
messageLongPressDelegates = []
view.subviews.forEach { $0.removeFromSuperview() }
let scrollView = UIScrollView()
let paddedContainerView = UIView()
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 12
var currentDaysBefore = -1
for (index, message) in pinnedMessages.reversed().enumerated() {
guard let renderItem = buildRenderItem(thread: threadViewModel.threadRecord, threadAssociatedData: threadViewModel.associatedData, message: message, tx: tx)
else {
continue
}
let longPressDelegate = PinnedMessageLongPressDelegate(itemViewModel: CVItemViewModelImpl(renderItem: renderItem))
longPressDelegate.actionDelegate = self
messageLongPressDelegates.append(longPressDelegate)
let itemDate = Date(millisecondsSince1970: message.timestamp)
let daysPrior = DateUtil.daysFrom(firstDate: itemDate, toSecondDate: Date())
if daysPrior != currentDaysBefore {
currentDaysBefore = daysPrior
let dateInteraction = DateHeaderInteraction(thread: threadViewModel.threadRecord, timestamp: message.timestamp)
if let dateItem = buildDateRenderItem(dateInteraction: dateInteraction, tx: tx) {
let cellView = CVCellView()
cellView.configure(renderItem: dateItem, componentDelegate: self)
cellView.isCellVisible = true
cellView.autoSetDimension(.height, toSize: dateItem.cellSize.height)
stack.addArrangedSubview(cellView)
}
}
stack.addArrangedSubview(
buildButtonAndCellStack(
renderItem: renderItem,
message: message,
reversedIndex: index,
),
)
}
paddedContainerView.addSubview(stack)
scrollView.addSubview(paddedContainerView)
view.addSubview(scrollView)
scrollView.autoPinEdgesToSuperviewSafeArea()
paddedContainerView.autoPinEdgesToSuperviewEdges()
stack.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 16, left: 16, bottom: 64, right: 16))
paddedContainerView.autoMatch(.width, to: .width, of: scrollView)
let unpinAllButton = UIButton(
configuration: .largeSecondary(title: OWSLocalizedString(
"PINNED_MESSAGES_UNPIN_ALL",
comment: "Title for a button to unpin all pinned messages.",
)),
primaryAction: UIAction { [weak self] _ in
self?.dismiss(animated: true)
self?.delegate?.unpinAllMessages()
},
)
if !threadViewModel.threadRecord.isTerminatedGroup {
view.addSubview(unpinAllButton)
unpinAllButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
unpinAllButton.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor, constant: -16),
unpinAllButton.centerXAnchor.constraint(equalTo: contentLayoutGuide.centerXAnchor),
])
}
}
private func updatePinnedMessageState() {
guard let threadId = threadViewModel.threadRecord.sqliteRowId else {
return
}
db.read { tx in
pinnedMessages = pinnedMessageManager.fetchPinnedMessagesForThread(threadId: threadId, tx: tx)
layoutPinnedMessages(tx: tx)
}
view.layoutIfNeeded()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let currentWidth = view.bounds.width
guard currentWidth != lastKnownWidth else { return }
lastKnownWidth = currentWidth
db.read { tx in
layoutPinnedMessages(tx: tx)
}
}
private func buildButtonAndCellStack(renderItem: CVRenderItem, message: TSMessage, reversedIndex: Int) -> UIStackView {
let cellHStack = UIStackView()
cellHStack.axis = .horizontal
cellHStack.alignment = .trailing
cellHStack.distribution = .fill
let goToMessageButton = UIButton()
goToMessageButton.setImage(.arrowRightCircle, for: .normal)
goToMessageButton.tintColor = UIColor.Signal.secondaryLabel
goToMessageButton.backgroundColor = UIColor.Signal.tertiaryFill
goToMessageButton.translatesAutoresizingMaskIntoConstraints = false
goToMessageButton.layer.cornerRadius = 18
goToMessageButton.clipsToBounds = true
goToMessageButton.tag = reversedIndex
goToMessageButton.addTarget(self, action: #selector(goToMessage), for: .touchUpInside)
let cellView = CVCellView()
cellView.configure(renderItem: renderItem, componentDelegate: self)
cellView.isCellVisible = true
cellView.autoSetDimension(.height, toSize: renderItem.cellSize.height)
cellView.autoSetDimension(.width, toSize: renderItem.cellSize.width)
let uiContextMenuInteraction = UIContextMenuInteraction(delegate: messageLongPressDelegates[reversedIndex])
cellView.addInteraction(uiContextMenuInteraction)
if let contentMenuContextView = cellView.componentView?.contextMenuContentView?() {
let uiContextMenuInteraction = UIContextMenuInteraction(delegate: messageLongPressDelegates[reversedIndex])
contentMenuContextView.addInteraction(uiContextMenuInteraction)
}
let spacer = UIView()
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
if message.isOutgoing {
cellHStack.addArrangedSubview(spacer)
cellHStack.addArrangedSubview(goToMessageButton)
cellHStack.addArrangedSubview(cellView)
} else {
cellHStack.addArrangedSubview(cellView)
cellHStack.addArrangedSubview(goToMessageButton)
cellHStack.addArrangedSubview(spacer)
}
NSLayoutConstraint.activate([
goToMessageButton.heightAnchor.constraint(equalToConstant: Self.goToMessageButtonSize),
goToMessageButton.widthAnchor.constraint(equalToConstant: Self.goToMessageButtonSize),
])
return cellHStack
}
private func buildDateRenderItem(dateInteraction: DateHeaderInteraction, tx: DBReadTransaction) -> CVRenderItem? {
let conversationStyle = ConversationStyle(
type: .messageDetails,
thread: threadViewModel.threadRecord,
viewWidth: view.safeAreaLayoutGuide.layoutFrame.width,
hasWallpaper: false,
shouldDimWallpaperInDarkMode: false,
isWallpaperPhoto: false,
chatColor: DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: threadViewModel.threadRecord,
tx: tx,
),
isStandaloneRenderItem: true,
)
let groupNameColors = GroupNameColors.forThread(threadViewModel.threadRecord)
return CVLoader.buildStandaloneRenderItem(
interaction: dateInteraction,
thread: threadViewModel.threadRecord,
threadAssociatedData: threadViewModel.associatedData,
conversationStyle: conversationStyle,
spoilerState: self.spoilerState,
groupNameColors: groupNameColors,
transaction: tx,
)
}
private func buildRenderItem(
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
message: TSMessage,
forceDateHeader: Bool = false,
tx: DBReadTransaction,
) -> CVRenderItem? {
let conversationStyle = ConversationStyle(
type: .messageDetails,
thread: thread,
viewWidth: view.safeAreaLayoutGuide.layoutFrame.width - Self.goToMessageButtonSize,
hasWallpaper: false,
shouldDimWallpaperInDarkMode: false,
isWallpaperPhoto: false,
chatColor: DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: tx,
),
)
let groupNameColors = GroupNameColors.forThread(threadViewModel.threadRecord)
return CVLoader.buildStandaloneRenderItem(
interaction: message,
thread: thread,
threadAssociatedData: threadAssociatedData,
conversationStyle: conversationStyle,
spoilerState: self.spoilerState,
groupNameColors: groupNameColors,
transaction: tx,
)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
self?.db.read { tx in
self?.layoutPinnedMessages(tx: tx)
}
}
}
// MARK: - Interactions
@objc
private func goToMessage(sender: UIButton) {
// We index in reverse order because of how UIKit lays out the pinned messages (top to bottom)
// versus how we store them for displaying in the CVC banner view (most -> least recent)
let reversedArray = pinnedMessages.reversed().map { $0 }
guard reversedArray.indices.contains(sender.tag) else {
return
}
let message = reversedArray[sender.tag]
delegate?.goToMessage(message: message)
dismiss(animated: true)
}
// MARK: - DatabaseChangeDelegate
func databaseChangesDidUpdate(databaseChanges: SignalServiceKit.DatabaseChanges) {
let pinnedMessagesSet = Set(pinnedMessages.map(\.uniqueId))
guard Set(databaseChanges.interactionUniqueIds).isDisjoint(with: pinnedMessagesSet) == false else {
return
}
updatePinnedMessageState()
}
func databaseChangesDidUpdateExternally() {
updatePinnedMessageState()
}
func databaseChangesDidReset() {
updatePinnedMessageState()
}
// MARK: - PinnedMessageLongPressActionDelegate
func deleteMessage(itemViewModel: CVItemViewModelImpl) {
itemViewModel.interaction.presentDeletionActionSheet(from: self)
}
func unpinMessage(itemViewModel: CVItemViewModelImpl) {
// if this is the last message, dismiss then unpin, otherwise, just unpin.
guard let message = itemViewModel.interaction as? TSMessage else { return }
if pinnedMessages.count <= 1 {
dismiss(animated: true)
delegate?.unpinMessage(message: message, modalDelegate: nil)
return
}
delegate?.unpinMessage(message: message, modalDelegate: self)
}
}
// MARK: - UIContextMenuInteractionDelegate
private class PinnedMessageLongPressDelegate: NSObject, UIContextMenuInteractionDelegate {
fileprivate protocol ActionDelegate: AnyObject {
func deleteMessage(itemViewModel: CVItemViewModelImpl)
func unpinMessage(itemViewModel: CVItemViewModelImpl)
}
let itemViewModel: CVItemViewModelImpl
weak var actionDelegate: ActionDelegate?
init(itemViewModel: CVItemViewModelImpl) {
self.itemViewModel = itemViewModel
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint,
) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil,
actionProvider: { [weak self] _ in
guard let self else { return UIMenu(children: []) }
var actions: [UIAction] = []
if itemViewModel.canCopyOrShareOrSpeakText {
actions.append(
UIAction(
title: OWSLocalizedString(
"CONTEXT_MENU_COPY",
comment: "Context menu button title",
),
image: .copyLight,
) { [weak self] _ in
self?.itemViewModel.copyTextAction()
},
)
}
if itemViewModel.canSaveMedia {
actions.append(
UIAction(
title: OWSLocalizedString(
"CONTEXT_MENU_SAVE_MEDIA",
comment: "Context menu button title",
),
image: .saveLight,
) { [weak self] _ in
self?.itemViewModel.saveMediaAction()
},
)
}
if !itemViewModel.thread.isTerminatedGroup {
actions.append(UIAction(
title: OWSLocalizedString(
"PINNED_MESSAGES_UNPIN",
comment: "Action menu item to unpin a message",
),
image: .pinSlash,
) { [weak self] _ in
guard let self else { return }
actionDelegate?.unpinMessage(itemViewModel: itemViewModel)
})
}
actions.append(
contentsOf: [
UIAction(
title: OWSLocalizedString(
"CONTEXT_MENU_DELETE_MESSAGE",
comment: "Context menu button title",
),
image: .trashLight,
) { [weak self] _ in
guard let self else { return }
actionDelegate?.deleteMessage(itemViewModel: itemViewModel)
},
],
)
return UIMenu(children: actions)
},
)
}
}
// MARK: - CVComponentDelegate
extension PinnedMessagesDetailsViewController: CVComponentDelegate {
func enqueueReload() {}
func enqueueReloadWithoutCaches() {}
func didTapBodyTextItem(_ item: CVTextLabel.Item) {}
func didLongPressBodyTextItem(_ item: CVTextLabel.Item) {}
func didTapSystemMessageItem(_ item: CVTextLabel.Item) {}
func didTapCollapseSet(collapseSetId: String) {}
func didDoubleTapTextViewItem(_ itemViewModel: CVItemViewModelImpl) {}
func didLongPressTextViewItem(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressMediaViewItem(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressQuote(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressSystemMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
) {}
func didLongPressSticker(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressPaymentMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didLongPressPoll(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool,
) {}
func didTapPayment(_ payment: PaymentsHistoryItem) {}
func didChangeLongPress(_ itemViewModel: CVItemViewModelImpl) {}
func didEndLongPress(_ itemViewModel: CVItemViewModelImpl) {}
func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl) {}
// MARK: -
func willBecomeVisibleWithSkippedDownloads(_ message: TSMessage) {}
func didTapSkippedDownloads(_ message: TSMessage) {}
func didCancelDownload(_ message: TSMessage, attachmentId: Attachment.IDType) {}
// MARK: -
func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {}
func didTapSenderAvatar(_ interaction: TSInteraction) {}
func shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool { false }
func didTapReactions(
reactionState: InteractionReactionState,
message: TSMessage,
) {}
func didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) {}
func didTapShowEditHistory(_ itemViewModel: CVItemViewModelImpl) {}
var hasPendingMessageRequest: Bool { false }
func didTapUndownloadableMedia() {}
func didTapUndownloadableGenericFile() {}
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
func didTapBodyMedia(
itemViewModel: CVItemViewModelImpl,
attachmentStream: ReferencedAttachmentStream,
imageView: UIView,
) {}
func didTapGenericAttachment(
_ attachment: CVComponentGenericAttachment,
) -> CVAttachmentTapAction { .default }
func didTapQuotedReply(_ quotedReply: QuotedReplyModel) {}
func didTapLinkPreview(url: URL) {}
func didTapContactShare(_ contactShare: ContactShareViewModel) {}
func didTapSendMessage(to phoneNumbers: [String]) {}
func didTapSendInvite(toContactShare contactShare: ContactShareViewModel) {}
func didTapAddToContacts(contactShare: ContactShareViewModel) {}
func didTapStickerPack(_ stickerPackInfo: StickerPackInfo) {}
func didTapGroupInviteLink(url: URL) {}
func didTapProxyLink(url: URL) {}
func didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {}
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 }
var selectionState: CVSelectionState { CVSelectionState() }
func didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {}
func didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {}
func didTapCorruptedMessage(_ message: TSErrorMessage) {}
func didTapSessionRefreshMessage(_ message: TSErrorMessage) {}
func didTapResendGroupUpdateForErrorMessage(_ errorMessage: TSErrorMessage) {}
func didTapShowFingerprint(_ address: SignalServiceAddress) {}
func didTapIndividualCall(_ call: TSCall) {}
func didTapLearnMoreMissedCallFromBlockedContact(_ call: TSCall) {}
func didTapGroupCall() {}
func didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {}
func didTapFailedMessage(_ message: TSMessage) {}
func didTapGroupMigrationLearnMore() {}
func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {}
func didTapViewGroupDescription(newGroupDescription: String) {}
func didTapNameEducation(type: SafetyTipsType) {}
func didTapShowConversationSettings() {}
func didTapShowConversationSettingsAndShowMemberRequests() {}
func didTapBlockRequest(
groupModel: TSGroupModelV2,
requesterName: String,
requesterAci: Aci,
) {}
func didTapShowUpgradeAppUI() {}
func didTapUpdateSystemContact(
_ address: SignalServiceAddress,
newNameComponents: PersonNameComponents,
) {}
func didTapPhoneNumberChange(aci: Aci, phoneNumberOld: String, phoneNumberNew: String) {}
func didTapViewOnceAttachment(_ interaction: TSInteraction) {}
func didTapViewOnceExpired(_ interaction: TSInteraction) {}
func didTapContactName(thread: TSContactThread) {}
func didTapUnknownThreadWarningGroup() {}
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() {}
}