Path: blob/main/Signal/ConversationView/ConversationViewController+PinnedMessages.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
import SignalUI
public import UIKit
protocol PinnedMessageInteractionManagerDelegate: AnyObject {
/// Scrolls to the message specified.
/// If nil, scrolls to the message found at pinnedMessageIndex, the current one displayed in the UI banner.
func goToMessage(message: TSMessage?)
/// Unpins the message specified.
/// If nil, unpins the message found at pinnedMessageIndex, the current one displayed in the UI banner.
func unpinMessage(message: TSMessage?, modalDelegate: UIViewController?)
/// Presents the "see all messages" details view.
func presentSeeAllMessages()
/// Unpins all messages
func unpinAllMessages()
}
public struct PinnedMessageBannerData {
let authorName: String
let previewText: NSAttributedString
let thumbnail: UIImageView?
}
public extension ConversationViewController {
private enum PinnedMessageBarSizing {
static let twoBarHeight = 12.0
static let threeBarHeight = 8.0
static let twoBarSpacing = 4.0
static let threeBarSpacing = 2.0
}
func handleTappedPinnedMessage() {
guard threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex) else {
owsFailDebug("Invalid pinned message index")
return
}
let currentPin = threadViewModel.pinnedMessages[pinnedMessageIndex]
if threadViewModel.pinnedMessages.count > 1 {
pinnedMessageIndex = (pinnedMessageIndex + 1) % threadViewModel.pinnedMessages.count
animateToNextPinnedMessage()
}
ensureInteractionLoadedThenScrollToInteraction(
currentPin.uniqueId,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: true,
)
}
func pinnedMessageLeadingAccessoryView() -> UIView? {
let pinnedMessages = threadViewModel.pinnedMessages
guard pinnedMessages.count > 1 else {
return nil
}
let accessoryViewContainer = UIStackView()
accessoryViewContainer.axis = .vertical
accessoryViewContainer.spacing = pinnedMessages.count == 2 ? PinnedMessageBarSizing.twoBarSpacing : PinnedMessageBarSizing.threeBarSpacing
accessoryViewContainer.alignment = .leading
accessoryViewContainer.translatesAutoresizingMaskIntoConstraints = false
let singleBarSize = pinnedMessages.count == 2 ? PinnedMessageBarSizing.twoBarHeight : PinnedMessageBarSizing.threeBarHeight
for (index, _) in pinnedMessages.enumerated().reversed() {
let verticalBar = UIView()
let color = index == pinnedMessageIndex ? UIColor.Signal.label : UIColor.Signal.tertiaryLabel
verticalBar.backgroundColor = color
verticalBar.layer.cornerRadius = 2
verticalBar.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
verticalBar.widthAnchor.constraint(equalToConstant: 2),
verticalBar.heightAnchor.constraint(equalToConstant: singleBarSize),
])
accessoryViewContainer.addArrangedSubview(verticalBar)
}
accessoryViewContainer.widthAnchor.constraint(equalToConstant: 2).isActive = true
return accessoryViewContainer
}
func pinnedMessageData(for message: TSMessage) -> PinnedMessageBannerData? {
func needsThumbnail(mimeType: String, attachment: ReferencedAttachment) -> Bool {
if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) || attachment.reference.renderingFlag == .shouldLoop {
return false
}
return MimeTypeUtil.isSupportedImageMimeType(mimeType) || MimeTypeUtil.isSupportedVideoMimeType(mimeType)
}
guard let messageRowId = message.grdbId?.int64Value else {
return nil
}
return DependenciesBridge.shared.db.read { tx in
let attachment = DependenciesBridge.shared.attachmentStore
.fetchAnyReferencedAttachment(for: .messageBodyAttachment(messageRowId: messageRowId), tx: tx)
var authorAddress: SignalServiceAddress?
if message.isOutgoing {
authorAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aciAddress
} else if let incomingMessage = message as? TSIncomingMessage {
authorAddress = incomingMessage.authorAddress
}
guard
let authorAddress,
let previewText = pinnedMessagePreviewText(tx, message: message, mediaAttachment: attachment)
else {
return nil
}
let voteAuthorName = SSKEnvironment.shared.contactManagerRef.nameForAddress(
authorAddress,
localUserDisplayMode: .asLocalUser,
short: false,
transaction: tx,
)
var thumbnail: UIImageView?
if let attachment, needsThumbnail(mimeType: attachment.attachment.mimeType, attachment: attachment) {
thumbnail = mediaAttachmentThumbnail(messageRowId: messageRowId, tx: tx)
}
return PinnedMessageBannerData(
authorName: voteAuthorName.string,
previewText: previewText,
thumbnail: thumbnail,
)
}
}
private func mediaSymbol(attachment: ReferencedAttachment?) -> NSAttributedString? {
guard let attachment else {
return nil
}
let mimeType = attachment.attachment.mimeType
if MimeTypeUtil.isSupportedAudioMimeType(mimeType) {
if attachment.reference.renderingFlag == .voiceMessage {
return SignalSymbol.audio.attributedString(
dynamicTypeBaseSize: 15.0,
)
}
}
if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) || attachment.reference.renderingFlag == .shouldLoop {
return SignalSymbol.gifRectangle.attributedString(
dynamicTypeBaseSize: 15.0,
)
} else if MimeTypeUtil.isSupportedImageMimeType(mimeType) || MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
// These will show a thumbnail instead.
return nil
} else {
return SignalSymbol.file.attributedString(
dynamicTypeBaseSize: 15.0,
)
}
}
private func pinnedMessagePreviewText(
_ tx: DBReadTransaction,
message: TSMessage,
mediaAttachment: ReferencedAttachment?,
) -> NSAttributedString? {
// Payments
if message is OWSPaymentMessage || message is OWSArchivedPaymentMessage {
let paymentIcon = SignalSymbol.creditcard.attributedString(
dynamicTypeBaseSize: 15.0,
) + " "
return paymentIcon + NSAttributedString(string: message.body ?? "")
}
// View once
if message.isViewOnceMessage {
let viewOnceIcon = SignalSymbol.viewOnce.attributedString(
dynamicTypeBaseSize: 15.0,
) + " "
return viewOnceIcon + NSAttributedString(string: OWSLocalizedString(
"PER_MESSAGE_EXPIRATION_NOT_VIEWABLE",
comment: "inbox cell and notification text for an already viewed view-once media message.",
))
}
// Regular body text
let bodyDescription = message.rawBody(transaction: tx)
if let bodyDescription = bodyDescription?.nilIfEmpty {
// Polls are a special case because the poll question is in the
// message body.
var pollPrefix: NSAttributedString = .init(string: "")
if message.isPoll {
let locPollString = OWSLocalizedString(
"POLL_PREFIX",
comment: "Prefix for a poll preview",
) + " "
let pollIcon = SignalSymbol.poll.attributedString(
dynamicTypeBaseSize: 15.0,
) + " "
pollPrefix = pollIcon + NSAttributedString(string: locPollString)
}
let hydrated = MessageBody(
text: bodyDescription,
ranges: message.bodyRanges ?? .empty,
).hydrating(mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: tx))
return pollPrefix + NSAttributedString(string: hydrated.asPlaintext())
}
// Attachments
let attachmentIcon = mediaSymbol(attachment: mediaAttachment)
let attachmentDescription = mediaAttachment?.previewText(includeFileName: true, includeEmoji: false)
if let attachmentDescription = attachmentDescription?.nilIfEmpty {
if let attachmentIcon {
return attachmentIcon + " " + NSAttributedString(string: attachmentDescription)
}
return NSAttributedString(string: attachmentDescription)
}
// Contact share
if let contactShare = message.contactShare {
let contactIcon = SignalSymbol.personCircle.attributedString(
dynamicTypeBaseSize: 15.0,
) + " "
return contactIcon + NSAttributedString(string: contactShare.name.displayName)
}
// Sticker
if message.messageSticker != nil {
let stickerIcon = SignalSymbol.sticker.attributedString(
dynamicTypeBaseSize: 15.0,
) + " "
let stickerDescription = OWSLocalizedString(
"STICKER_MESSAGE_PREVIEW",
comment: "Preview text shown in notifications and conversation list for sticker messages.",
)
return stickerIcon + NSAttributedString(string: stickerDescription)
}
// Unknown
return nil
}
private func mediaAttachmentThumbnail(messageRowId: Int64, tx: DBReadTransaction) -> UIImageView? {
let attachment = DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment(
for: .messageBodyAttachment(messageRowId: messageRowId),
tx: tx,
)
guard let attachment, let attachmentStream = attachment.asReferencedStream else {
return nil
}
let imageView = UIImageView()
imageView.clipsToBounds = true
if #available(iOS 26, *) {
imageView.layer.cornerCurve = .continuous
imageView.layer.cornerRadius = 11
} else {
imageView.layer.cornerRadius = 4
}
imageView.contentMode = .scaleAspectFill
imageView.image = attachmentStream.attachmentStream.thumbnailImageSync(quality: .small)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 30),
imageView.heightAnchor.constraint(equalToConstant: 30),
])
return imageView
}
}
extension ConversationViewController: UIContextMenuInteractionDelegate {
public func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint,
) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
var actions: [UIAction] = []
guard let self else { return UIMenu() }
if !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 }
if threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex) {
handleActionUnpin(
message: threadViewModel.pinnedMessages[pinnedMessageIndex],
modalDelegate: self,
)
}
})
}
actions.append(contentsOf: [
UIAction(
title: OWSLocalizedString(
"PINNED_MESSAGES_GO_TO_MESSAGE",
comment: "Action menu item to go to a message in the conversation view",
),
image: .chatArrow,
) { [weak self] _ in
guard let self else { return }
if threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex) {
goToMessage(message: threadViewModel.pinnedMessages[pinnedMessageIndex])
}
},
UIAction(title: OWSLocalizedString(
"PINNED_MESSAGES_SEE_ALL_MESSAGES",
comment: "Action menu item to see all pinned messages",
), image: .listBullet) { [weak self] _ in
self?.presentSeeAllMessages()
},
])
return UIMenu(children: actions)
}
}
}
extension ConversationViewController: PinnedMessageInteractionManagerDelegate {
func goToMessage(message: TSMessage?) {
let targetMessage: TSMessage
if let message {
targetMessage = message
} else {
guard threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex) else {
return
}
targetMessage = threadViewModel.pinnedMessages[pinnedMessageIndex]
}
ensureInteractionLoadedThenScrollToInteraction(
targetMessage.uniqueId,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: true,
)
}
func unpinMessage(message: TSMessage?, modalDelegate: UIViewController?) {
let messageToUnpin: TSMessage
if let message {
messageToUnpin = message
} else {
guard threadViewModel.pinnedMessages.indices.contains(pinnedMessageIndex) else {
return
}
messageToUnpin = threadViewModel.pinnedMessages[pinnedMessageIndex]
}
handleActionUnpin(message: messageToUnpin, modalDelegate: modalDelegate ?? self)
}
func presentSeeAllMessages() {
let pmDetailsController = UINavigationController(rootViewController: PinnedMessagesDetailsViewController(
pinnedMessages: threadViewModel.pinnedMessages,
threadViewModel: threadViewModel,
database: DependenciesBridge.shared.db,
delegate: self,
databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
pinnedMessageManager: DependenciesBridge.shared.pinnedMessageManager,
spoilerState: self.viewState.spoilerState,
))
pmDetailsController.modalPresentationStyle = .pageSheet
present(pmDetailsController, animated: true)
}
func unpinAllMessages() {
Task {
for message in threadViewModel.pinnedMessages {
await handleActionUnpinAsync(message: message)
}
presentToastCVC(OWSLocalizedString(
"PINNED_MESSAGE_TOAST",
comment: "Text to show on a toast when someone unpins a message",
))
}
}
}