Signal-iOS/Signal/src/ViewControllers/ConversationView/CV/CVComponents/CVComponentSystemMessage.swift
Igor Solomennikov 0c42c42eca
Remove "cvc_" prefix chat view code
This change should have no user impact.
2022-11-15 10:31:10 -06:00

1298 lines
60 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalMessaging
import SignalServiceKit
public class CVComponentSystemMessage: CVComponentBase, CVRootComponent {
public var componentKey: CVComponentKey { .systemMessage }
public var cellReuseIdentifier: CVCellReuseIdentifier {
CVCellReuseIdentifier.systemMessage
}
public let isDedicatedCell = true
private let systemMessage: CVComponentState.SystemMessage
typealias Action = CVMessageAction
fileprivate var action: Action? { systemMessage.action }
required init(itemModel: CVItemModel, systemMessage: CVComponentState.SystemMessage) {
self.systemMessage = systemMessage
super.init(itemModel: itemModel)
}
public func configureCellRootComponent(cellView: UIView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState,
componentView: CVComponentView) {
Self.configureCellRootComponent(rootComponent: self,
cellView: cellView,
cellMeasurement: cellMeasurement,
componentDelegate: componentDelegate,
componentView: componentView)
}
private var bubbleBackgroundColor: UIColor {
Theme.backgroundColor
}
private var outerHStackConfig: CVStackViewConfig {
let cellLayoutMargins = UIEdgeInsets(top: 0,
leading: conversationStyle.fullWidthGutterLeading,
bottom: 0,
trailing: conversationStyle.fullWidthGutterTrailing)
return CVStackViewConfig(axis: .horizontal,
alignment: .fill,
spacing: ConversationStyle.messageStackSpacing,
layoutMargins: cellLayoutMargins)
}
private var innerVStackConfig: CVStackViewConfig {
let layoutMargins: UIEdgeInsets
if itemModel.itemViewState.isFirstInCluster {
layoutMargins = UIEdgeInsets(hMargin: 10, vMargin: 10)
} else {
layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 10, right: 10)
}
return CVStackViewConfig(axis: .vertical,
alignment: .center,
spacing: 12,
layoutMargins: layoutMargins)
}
private var outerVStackConfig: CVStackViewConfig {
return CVStackViewConfig(axis: .vertical,
alignment: .center,
spacing: 0,
layoutMargins: .zero)
}
public override func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
guard let componentView = componentView as? CVComponentViewSystemMessage else {
owsFailDebug("Unexpected componentView.")
return nil
}
return componentView.wallpaperBlurView
}
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewSystemMessage()
}
public func configureForRendering(componentView: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate) {
guard let componentView = componentView as? CVComponentViewSystemMessage else {
owsFailDebug("Unexpected componentView.")
return
}
let themeHasChanged = conversationStyle.isDarkThemeEnabled != componentView.isDarkThemeEnabled
let hasWallpaper = conversationStyle.hasWallpaper
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
let isFirstInCluster = itemModel.itemViewState.isFirstInCluster
let isLastInCluster = itemModel.itemViewState.isLastInCluster
let hasClusteringChanges = (componentView.isFirstInCluster != isFirstInCluster ||
componentView.isLastInCluster != isLastInCluster)
let hasSelectionChanges = (componentView.isShowingSelectionUI != isShowingSelectionUI ||
componentView.wasShowingSelectionUI != wasShowingSelectionUI)
var hasActionButton = false
if nil != action,
!itemViewState.shouldCollapseSystemMessageAction,
nil != cellMeasurement.size(key: Self.measurementKey_buttonSize) {
hasActionButton = true
}
let isReusing = (componentView.rootView.superview != nil &&
!themeHasChanged &&
!wallpaperModeHasChanged &&
!hasClusteringChanges &&
!hasSelectionChanges &&
!hasActionButton &&
!componentView.hasActionButton)
if !isReusing {
componentView.reset(resetReusableState: true)
}
componentView.isDarkThemeEnabled = conversationStyle.isDarkThemeEnabled
componentView.hasWallpaper = hasWallpaper
componentView.isFirstInCluster = isFirstInCluster
componentView.isLastInCluster = isLastInCluster
componentView.isShowingSelectionUI = isShowingSelectionUI
componentView.wasShowingSelectionUI = wasShowingSelectionUI
componentView.hasActionButton = hasActionButton
let outerHStack = componentView.outerHStack
let innerVStack = componentView.innerVStack
let outerVStack = componentView.outerVStack
let selectionView = componentView.selectionView
let textLabel = componentView.textLabel
// Configuring the text label should happen in both reuse and non-reuse
// scenarios
textLabel.configureForRendering(config: textLabelConfig)
textLabel.view.accessibilityLabel = textLabelConfig.attributedString.string
if isReusing {
innerVStack.configureForReuse(config: innerVStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerVStack)
outerVStack.configureForReuse(config: outerVStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerVStack)
outerHStack.configureForReuse(config: outerHStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerHStack)
if hasWallpaper,
let wallpaperBlurView = componentView.wallpaperBlurView {
wallpaperBlurView.applyLayout()
wallpaperBlurView.updateIfNecessary()
}
} else {
var innerVStackViews: [UIView] = [
textLabel.view
]
let outerVStackViews = [
innerVStack
]
var outerHStackViews = [UIView]()
if isShowingSelectionUI || wasShowingSelectionUI {
// System messages cannot be partially selected.
selectionView.isSelected = componentDelegate.selectionState.hasAnySelection(interaction: interaction)
selectionView.updateStyle(conversationStyle: conversationStyle)
outerHStackViews.append(selectionView)
}
outerHStackViews.append(contentsOf: [
UIView.transparentSpacer(),
outerVStack,
UIView.transparentSpacer()
])
if let action = action,
!itemViewState.shouldCollapseSystemMessageAction,
let actionButtonSize = cellMeasurement.size(key: Self.measurementKey_buttonSize) {
let buttonLabelConfig = self.buttonLabelConfig(action: action)
let button = OWSButton(title: action.title) {}
componentView.button = button
button.accessibilityIdentifier = action.accessibilityIdentifier
button.titleLabel?.textAlignment = .center
button.titleLabel?.font = buttonLabelConfig.font
button.setTitleColor(buttonLabelConfig.textColor, for: .normal)
if nil != interaction as? OWSGroupCallMessage {
button.backgroundColor = UIColor.ows_accentGreen
} else {
if isDarkThemeEnabled && hasWallpaper {
button.backgroundColor = .ows_gray65
} else {
button.backgroundColor = Theme.conversationButtonBackgroundColor
}
}
button.contentEdgeInsets = buttonContentEdgeInsets
button.layer.cornerRadius = actionButtonSize.height / 2
button.isUserInteractionEnabled = false
innerVStackViews.append(button)
}
innerVStack.configure(config: innerVStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerVStack,
subviews: innerVStackViews)
outerVStack.configure(config: outerVStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerVStack,
subviews: outerVStackViews)
outerHStack.configure(config: outerHStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerHStack,
subviews: outerHStackViews)
componentView.wallpaperBlurView?.removeFromSuperview()
componentView.wallpaperBlurView = nil
componentView.backgroundView?.removeFromSuperview()
componentView.backgroundView = nil
let bubbleView: UIView
if hasWallpaper {
let wallpaperBlurView = componentView.ensureWallpaperBlurView()
configureWallpaperBlurView(wallpaperBlurView: wallpaperBlurView,
maskCornerRadius: 0,
componentDelegate: componentDelegate)
bubbleView = wallpaperBlurView
} else {
let backgroundView = UIView()
backgroundView.backgroundColor = Theme.backgroundColor
componentView.backgroundView = backgroundView
bubbleView = backgroundView
}
if isFirstInCluster && isLastInCluster {
innerVStack.addSubviewToFillSuperviewEdges(bubbleView)
innerVStack.sendSubviewToBack(bubbleView)
bubbleView.layer.cornerRadius = 8
bubbleView.layer.maskedCorners = .all
bubbleView.clipsToBounds = true
} else {
outerVStack.addSubviewToFillSuperviewEdges(bubbleView)
outerVStack.sendSubviewToBack(bubbleView)
if isFirstInCluster {
bubbleView.layer.cornerRadius = 12
bubbleView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
bubbleView.clipsToBounds = true
} else if isLastInCluster {
bubbleView.layer.cornerRadius = 12
bubbleView.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner]
bubbleView.clipsToBounds = true
}
}
}
// Configure hOuterStack/hInnerStack animations animations
if isShowingSelectionUI || wasShowingSelectionUI {
// Configure selection animations
let selectionViewWidth = ConversationStyle.selectionViewWidth
let layoutMargins = CurrentAppContext().isRTL ? outerHStackConfig.layoutMargins.right : outerHStackConfig.layoutMargins.left
let selectionOffset = -(layoutMargins + selectionViewWidth)
let outerVStackOffset = -(outerHStackConfig.spacing + selectionViewWidth - layoutMargins)
if isShowingSelectionUI && !wasShowingSelectionUI { // Animate in
selectionView.addTransformBlock { view in
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = selectionOffset
animation.toValue = 0
animation.duration = CVComponentMessage.selectionAnimationDuration
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
view.layer.add(animation, forKey: "insert")
}
outerVStack.addTransformBlock { view in
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = outerVStackOffset
animation.toValue = 0
animation.duration = CVComponentMessage.selectionAnimationDuration
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
view.layer.add(animation, forKey: "insert")
}
} else if !isShowingSelectionUI && wasShowingSelectionUI { // Animate out
selectionView.addTransformBlock { view in
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = selectionOffset
animation.duration = CVComponentMessage.selectionAnimationDuration
animation.isRemovedOnCompletion = false
animation.repeatCount = 0
animation.fillMode = CAMediaTimingFillMode.forwards
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
view.layer.add(animation, forKey: "remove")
}
outerVStack.addTransformBlock { view in
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = outerVStackOffset
animation.duration = CVComponentMessage.selectionAnimationDuration
animation.isRemovedOnCompletion = false
animation.repeatCount = 0
animation.fillMode = CAMediaTimingFillMode.forwards
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
view.layer.add(animation, forKey: "remove")
}
}
} else {
// Remove outstanding animations if needed
let selectionView = componentView.selectionView
selectionView.invalidateTransformBlocks()
outerVStack.invalidateTransformBlocks()
}
outerHStack.applyTransformBlocks()
}
private var textLabelConfig: CVTextLabel.Config {
let selectionStyling: [NSAttributedString.Key: Any] = [
.backgroundColor: systemMessage.titleSelectionBackgroundColor
]
return CVTextLabel.Config(
attributedString: systemMessage.title,
font: Self.textLabelFont,
textColor: systemMessage.titleColor,
selectionStyling: selectionStyling,
textAlignment: .center,
lineBreakMode: .byWordWrapping,
items: systemMessage.namesInTitle.map { .referencedUser(referencedUserItem: $0) }
)
}
private func buttonLabelConfig(action: Action) -> CVLabelConfig {
let textColor: UIColor
if nil != interaction as? OWSGroupCallMessage {
textColor = Theme.isDarkThemeEnabled ? .ows_whiteAlpha90 : .white
} else {
textColor = Theme.conversationButtonTextColor
}
return CVLabelConfig(text: action.title,
font: UIFont.ows_dynamicTypeFootnote.ows_semibold,
textColor: textColor,
textAlignment: .center)
}
private var buttonContentEdgeInsets: UIEdgeInsets {
UIEdgeInsets(hMargin: 12, vMargin: 6)
}
private static var textLabelFont: UIFont {
UIFont.ows_dynamicTypeFootnote
}
private static let measurementKey_outerHStack = "CVComponentSystemMessage.measurementKey_outerHStack"
private static let measurementKey_innerVStack = "CVComponentSystemMessage.measurementKey_innerVStack"
private static let measurementKey_outerVStack = "CVComponentSystemMessage.measurementKey_outerVStack"
private static let measurementKey_buttonSize = "CVComponentSystemMessage.measurementKey_buttonSize"
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
var maxContentWidth = (maxWidth -
(outerHStackConfig.layoutMargins.totalWidth +
outerVStackConfig.layoutMargins.totalWidth +
innerVStackConfig.layoutMargins.totalWidth))
let selectionViewSize = CGSize(width: ConversationStyle.selectionViewWidth, height: 0)
if isShowingSelectionUI || wasShowingSelectionUI {
// Account for selection UI when doing measurement.
maxContentWidth -= selectionViewSize.width + outerHStackConfig.spacing
}
// Padding around the outerVStack (leading and trailing side)
maxContentWidth -= (outerHStackConfig.spacing + minBubbleHMargin) * 2
maxContentWidth = max(0, maxContentWidth)
let textSize = CVTextLabel.measureSize(
config: textLabelConfig,
maxWidth: maxContentWidth
)
var innerVStackSubviewInfos = [ManualStackSubviewInfo]()
innerVStackSubviewInfos.append(textSize.size.asManualSubviewInfo)
if let action = action, !itemViewState.shouldCollapseSystemMessageAction {
let buttonLabelConfig = self.buttonLabelConfig(action: action)
let actionButtonSize = (CVText.measureLabel(config: buttonLabelConfig,
maxWidth: maxContentWidth) +
buttonContentEdgeInsets.asSize)
measurementBuilder.setSize(key: Self.measurementKey_buttonSize, size: actionButtonSize)
innerVStackSubviewInfos.append(actionButtonSize.asManualSubviewInfo(hasFixedSize: true))
}
let innerVStackMeasurement = ManualStackView.measure(config: innerVStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerVStack,
subviewInfos: innerVStackSubviewInfos)
let outerVStackSubviewInfos: [ManualStackSubviewInfo] = [
innerVStackMeasurement.measuredSize.asManualSubviewInfo
]
let outerVStackMeasurement = ManualStackView.measure(config: outerVStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerVStack,
subviewInfos: outerVStackSubviewInfos)
var outerHStackSubviewInfos = [ManualStackSubviewInfo]()
if isShowingSelectionUI || wasShowingSelectionUI {
outerHStackSubviewInfos.append(selectionViewSize.asManualSubviewInfo(hasFixedWidth: true))
}
outerHStackSubviewInfos.append(contentsOf: [
CGSize(width: minBubbleHMargin, height: 0).asManualSubviewInfo(hasFixedWidth: true),
outerVStackMeasurement.measuredSize.asManualSubviewInfo,
CGSize(width: minBubbleHMargin, height: 0).asManualSubviewInfo(hasFixedWidth: true)
])
let outerHStackMeasurement = ManualStackView.measure(config: outerHStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerHStack,
subviewInfos: outerHStackSubviewInfos,
maxWidth: maxWidth)
return outerHStackMeasurement.measuredSize
}
private let minBubbleHMargin: CGFloat = 4
// MARK: - Events
public override func handleTap(sender: UITapGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> Bool {
guard let componentView = componentView as? CVComponentViewSystemMessage else {
owsFailDebug("Unexpected componentView.")
return false
}
if isShowingSelectionUI {
let selectionView = componentView.selectionView
// System messages cannot be partially selected.
let selectionState = componentDelegate.selectionState
if selectionState.hasAnySelection(interaction: interaction) {
selectionView.isSelected = false
selectionState.remove(interaction: interaction, selectionType: .allContent)
} else {
selectionView.isSelected = true
selectionState.add(interaction: interaction, selectionType: .allContent)
}
// Suppress other tap handling during selection mode.
return true
}
if
let action = systemMessage.action,
let actionButton = componentView.button,
actionButton.containsGestureLocation(sender)
{
action.action.perform(delegate: componentDelegate)
return true
}
if let item = componentView.textLabel.itemForGesture(sender: sender) {
componentDelegate.didTapSystemMessageItem(item)
return true
}
return false
}
public override func findLongPressHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVLongPressHandler? {
return CVLongPressHandler(delegate: componentDelegate,
renderItem: renderItem,
gestureLocation: .systemMessage)
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
public class CVComponentViewSystemMessage: NSObject, CVComponentView {
fileprivate let outerHStack = ManualStackView(name: "systemMessage.outerHStack")
fileprivate let innerVStack = ManualStackView(name: "systemMessage.innerVStack")
fileprivate let outerVStack = ManualStackView(name: "systemMessage.outerVStack")
fileprivate let selectionView = MessageSelectionView()
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView = self.wallpaperBlurView {
return wallpaperBlurView
}
let wallpaperBlurView = CVWallpaperBlurView()
self.wallpaperBlurView = wallpaperBlurView
return wallpaperBlurView
}
fileprivate var backgroundView: UIView?
public let textLabel = CVTextLabel()
public fileprivate(set) var button: OWSButton?
fileprivate var hasWallpaper = false
fileprivate var isDarkThemeEnabled = false
fileprivate var isFirstInCluster = false
fileprivate var isLastInCluster = false
public var isDedicatedCellView = false
public var isShowingSelectionUI = false
public var wasShowingSelectionUI = false
public var hasActionButton = false
public var rootView: UIView {
outerHStack
}
// MARK: -
override required init() {
super.init()
}
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
reset(resetReusableState: false)
}
public func reset(resetReusableState: Bool) {
owsAssertDebug(isDedicatedCellView)
if resetReusableState {
outerHStack.reset()
innerVStack.reset()
outerVStack.reset()
textLabel.reset()
wallpaperBlurView?.removeFromSuperview()
wallpaperBlurView?.resetContentAndConfiguration()
backgroundView?.removeFromSuperview()
backgroundView = nil
hasWallpaper = false
isDarkThemeEnabled = false
isFirstInCluster = false
isLastInCluster = false
isShowingSelectionUI = false
wasShowingSelectionUI = false
hasActionButton = false
}
button?.removeFromSuperview()
button = nil
}
}
}
// MARK: -
extension CVComponentSystemMessage {
static func buildComponentState(
title: NSAttributedString,
action: Action?,
titleColor: UIColor? = nil,
titleSelectionBackgroundColor: UIColor? = nil
) -> CVComponentState.SystemMessage {
return CVComponentState.SystemMessage(
title: title,
titleColor: titleColor ?? defaultTextColor,
titleSelectionBackgroundColor: titleSelectionBackgroundColor ?? defaultSelectionBackgroundColor,
action: action
)
}
static func buildComponentState(interaction: TSInteraction,
threadViewModel: ThreadViewModel,
currentCallThreadId: String?,
transaction: SDSAnyReadTransaction) -> CVComponentState.SystemMessage {
let title = Self.title(forInteraction: interaction, transaction: transaction)
let maybeOverrideTitleColor = Self.overrideTextColor(forInteraction: interaction)
let action = Self.action(forInteraction: interaction,
threadViewModel: threadViewModel,
currentCallThreadId: currentCallThreadId,
transaction: transaction)
return buildComponentState(title: title, action: action, titleColor: maybeOverrideTitleColor)
}
private static func title(forInteraction interaction: TSInteraction,
transaction: SDSAnyReadTransaction) -> NSAttributedString {
let font = Self.textLabelFont
let labelText = NSMutableAttributedString()
if let infoMessage = interaction as? TSInfoMessage,
infoMessage.messageType == .typeGroupUpdate,
let groupUpdates = infoMessage.groupUpdateItems(transaction: transaction),
!groupUpdates.isEmpty {
for (index, update) in groupUpdates.enumerated() {
let iconName = Self.iconName(forGroupUpdateType: update.type)
labelText.appendTemplatedImage(named: iconName,
font: font,
heightReference: ImageAttachmentHeightReference.lineHeight)
labelText.append(" ", attributes: [:])
labelText.append(update.text)
let isLast = index == groupUpdates.count - 1
if !isLast {
labelText.append("\n", attributes: [:])
}
}
if groupUpdates.count > 1 {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.paragraphSpacing = 12
paragraphStyle.alignment = .center
labelText.addAttributeToEntireString(.paragraphStyle, value: paragraphStyle)
}
return labelText
}
if let icon = icon(forInteraction: interaction) {
labelText.appendImage(icon.withRenderingMode(.alwaysTemplate),
font: font,
heightReference: ImageAttachmentHeightReference.lineHeight)
labelText.append(" ", attributes: [:])
}
let systemMessageText = Self.systemMessageText(forInteraction: interaction,
transaction: transaction)
owsAssertDebug(!systemMessageText.isEmpty)
labelText.append(systemMessageText)
let shouldShowTimestamp = interaction.interactionType == .call
if shouldShowTimestamp {
// FIXME: This is not the correct way to localize a date and time; they should be formatted together.
labelText.append(LocalizationNotNeeded(" · "))
labelText.append(DateUtil.formatTimestampAsDate(interaction.timestamp))
labelText.append(LocalizationNotNeeded(" "))
labelText.append(DateUtil.formatTimestampAsTime(interaction.timestamp))
}
return labelText
}
private static func systemMessageText(forInteraction interaction: TSInteraction,
transaction: SDSAnyReadTransaction) -> String {
if let errorMessage = interaction as? TSErrorMessage {
return errorMessage.previewText(transaction: transaction)
} else if let verificationMessage = interaction as? OWSVerificationStateChangeMessage {
let isVerified = verificationMessage.verificationState == .verified
let displayName = contactsManager.displayName(for: verificationMessage.recipientAddress, transaction: transaction)
let format = (isVerified
? (verificationMessage.isLocalChange
? NSLocalizedString("VERIFICATION_STATE_CHANGE_FORMAT_VERIFIED_LOCAL",
comment: "Format for info message indicating that the verification state was verified on this device. Embeds {{user's name or phone number}}.")
: NSLocalizedString("VERIFICATION_STATE_CHANGE_FORMAT_VERIFIED_OTHER_DEVICE",
comment: "Format for info message indicating that the verification state was verified on another device. Embeds {{user's name or phone number}}."))
: (verificationMessage.isLocalChange
? NSLocalizedString("VERIFICATION_STATE_CHANGE_FORMAT_NOT_VERIFIED_LOCAL",
comment: "Format for info message indicating that the verification state was unverified on this device. Embeds {{user's name or phone number}}.")
: NSLocalizedString("VERIFICATION_STATE_CHANGE_FORMAT_NOT_VERIFIED_OTHER_DEVICE",
comment: "Format for info message indicating that the verification state was unverified on another device. Embeds {{user's name or phone number}}.")))
return String(format: format, displayName)
} else if let infoMessage = interaction as? TSInfoMessage {
return infoMessage.systemMessageText(with: transaction)
} else if let call = interaction as? TSCall {
return call.previewText(transaction: transaction)
} else if let groupCall = interaction as? OWSGroupCallMessage {
return groupCall.systemText(with: transaction)
} else {
owsFailDebug("Not a system message.")
return ""
}
}
private static var defaultTextColor: UIColor { Theme.secondaryTextAndIconColor }
private static var defaultSelectionBackgroundColor: UIColor {
Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_gray05
}
private static func overrideTextColor(forInteraction interaction: TSInteraction) -> UIColor? {
if let call = interaction as? TSCall {
switch call.callType {
case .incomingMissed,
.incomingMissedBecauseOfChangedIdentity,
.incomingMissedBecauseOfDoNotDisturb,
.incomingBusyElsewhere,
.incomingDeclined,
.incomingDeclinedElsewhere:
// We use a custom red here, as we consider changing
// our red everywhere for better accessibility
return UIColor(rgbHex: 0xE51D0E)
default:
return nil
}
} else {
return nil
}
}
private static func icon(forInteraction interaction: TSInteraction) -> UIImage? {
if let errorMessage = interaction as? TSErrorMessage {
switch errorMessage.errorType {
case .nonBlockingIdentityChange,
.wrongTrustedIdentityKey:
return Theme.iconImage(.safetyNumber16)
case .sessionRefresh:
return Theme.iconImage(.refresh16)
case .decryptionFailure:
return Theme.iconImage(.error16)
case .invalidKeyException,
.missingKeyId,
.noSession,
.invalidMessage,
.duplicateMessage,
.invalidVersion,
.unknownContactBlockOffer,
.groupCreationFailed:
return nil
}
} else if let infoMessage = interaction as? TSInfoMessage {
switch infoMessage.messageType {
case .userNotRegistered,
.typeSessionDidEnd,
.typeUnsupportedMessage,
.addToContactsOffer,
.addUserToProfileWhitelistOffer,
.addGroupToProfileWhitelistOffer:
return nil
case .typeGroupUpdate,
.typeGroupQuit:
return Theme.iconImage(.group16)
case .unknownProtocolVersion:
guard let message = interaction as? OWSUnknownProtocolVersionMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
return Theme.iconImage(message.isProtocolVersionUnknown ? .error16 : .check16)
case .typeDisappearingMessagesUpdate:
guard let message = interaction as? OWSDisappearingConfigurationUpdateInfoMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
let areDisappearingMessagesEnabled = message.configurationIsEnabled
return Theme.iconImage(areDisappearingMessagesEnabled ? .timer16 : .timerDisabled16)
case .verificationStateChange:
guard let message = interaction as? OWSVerificationStateChangeMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
guard message.verificationState == .verified else {
return nil
}
return Theme.iconImage(.check16)
case .userJoinedSignal:
return Theme.iconImage(.heart16)
case .syncedThread:
return Theme.iconImage(.info16)
case .profileUpdate:
return Theme.iconImage(.profile16)
case .phoneNumberChange:
let offerTypeString = "phone"
let themeString = Theme.isDarkThemeEnabled ? "solid" : "outline"
let imageName = "\(offerTypeString)-\(themeString)-16"
return UIImage(named: imageName)
}
} else if let call = interaction as? TSCall {
let offerTypeString: String
switch call.offerType {
case .audio:
offerTypeString = "phone"
case .video:
offerTypeString = "video"
}
let directionString: String
switch call.callType {
case .incomingMissed,
.incomingMissedBecauseOfChangedIdentity,
.incomingMissedBecauseOfDoNotDisturb,
.incomingBusyElsewhere,
.incomingDeclined,
.incomingDeclinedElsewhere:
directionString = "x"
case .incoming,
.incomingIncomplete,
.incomingAnsweredElsewhere:
directionString = "incoming"
case .outgoing,
.outgoingIncomplete,
.outgoingMissed:
directionString = "outgoing"
@unknown default:
owsFailDebug("Unknown value.")
return nil
}
let themeString = Theme.isDarkThemeEnabled ? "solid" : "outline"
let imageName = "\(offerTypeString)-\(directionString)-\(themeString)-16"
return UIImage(named: imageName)
} else if nil != interaction as? OWSGroupCallMessage {
let imageName = Theme.isDarkThemeEnabled ? "video-solid-16" : "video-outline-16"
return UIImage(named: imageName)
} else {
owsFailDebug("Unknown interaction type: \(type(of: interaction))")
return nil
}
}
private static func iconName(forGroupUpdateType groupUpdateType: GroupUpdateType) -> String {
switch groupUpdateType {
case .userMembershipState_left:
return Theme.iconName(.leave16)
case .userMembershipState_removed:
return Theme.iconName(.memberRemove16)
case .userMembershipState_invited,
.userMembershipState_added,
.userMembershipState_invitesNew:
return Theme.iconName(.memberAdded16)
case .groupCreated,
.generic,
.debug,
.userMembershipState,
.userMembershipState_invalidInvitesRemoved,
.userMembershipState_invalidInvitesAdded,
.groupInviteLink,
.groupGroupLinkPromotion:
return Theme.iconName(.group16)
case .userMembershipState_invitesDeclined,
.userMembershipState_invitesRevoked:
return Theme.iconName(.memberDeclined16)
case .accessAttributes,
.accessMembers,
.userRole:
return Theme.iconName(.megaphone16)
case .groupName,
.groupDescriptionUpdated,
.groupDescriptionRemoved:
return Theme.iconName(.compose16)
case .groupAvatar:
return Theme.iconName(.photo16)
case .disappearingMessagesState,
.disappearingMessagesState_enabled:
return Theme.iconName(.timer16)
case .disappearingMessagesState_disabled:
return Theme.iconName(.timerDisabled16)
case .groupMigrated:
return Theme.iconName(.megaphone16)
case .groupMigrated_usersInvited:
return Theme.iconName(.memberAdded16)
case .groupMigrated_usersDropped:
return Theme.iconName(.group16)
case .isAnnouncementOnly:
return Theme.iconName(.megaphone16)
}
}
// MARK: - Unknown Thread Warning
static func buildUnknownThreadWarningState(interaction: TSInteraction,
threadViewModel: ThreadViewModel,
transaction: SDSAnyReadTransaction) -> CVComponentState.SystemMessage {
if threadViewModel.isGroupThread {
let title = NSLocalizedString("SYSTEM_MESSAGE_UNKNOWN_THREAD_WARNING_GROUP",
comment: "Indicator warning about an unknown group thread.")
let labelText = NSMutableAttributedString()
labelText.appendTemplatedImage(named: Theme.iconName(.info16),
font: Self.textLabelFont,
heightReference: ImageAttachmentHeightReference.lineHeight)
labelText.append(" ", attributes: [:])
labelText.append(title, attributes: [:])
let action = Action(title: CommonStrings.learnMore,
accessibilityIdentifier: "unknown_thread_warning",
action: .didTapUnknownThreadWarningGroup)
return buildComponentState(title: labelText, action: action)
} else {
let title = NSLocalizedString("SYSTEM_MESSAGE_UNKNOWN_THREAD_WARNING_CONTACT",
comment: "Indicator warning about an unknown contact thread.")
let action = Action(title: CommonStrings.learnMore,
accessibilityIdentifier: "unknown_thread_warning",
action: .didTapUnknownThreadWarningContact)
return buildComponentState(title: title.attributedString(), action: action)
}
}
// MARK: - Default Disappearing Message Timer
static func buildDefaultDisappearingMessageTimerState(
interaction: TSInteraction,
threadViewModel: ThreadViewModel,
transaction: SDSAnyReadTransaction
) -> CVComponentState.SystemMessage {
let configuration = OWSDisappearingMessagesConfiguration.fetchOrBuildDefaultUniversalConfiguration(with: transaction)
let labelText = NSMutableAttributedString()
labelText.appendImage(
Theme.iconImage(.timer16).withRenderingMode(.alwaysTemplate),
font: Self.textLabelFont,
heightReference: ImageAttachmentHeightReference.lineHeight
)
labelText.append(" ", attributes: [:])
let titleFormat = NSLocalizedString(
"SYSTEM_MESSAGE_DEFAULT_DISAPPEARING_MESSAGE_TIMER_FORMAT",
comment: "Indicator that the default disappearing message timer will be applied when you send a message. Embeds {default disappearing message time}"
)
labelText.append(String(format: titleFormat, configuration.durationString))
return buildComponentState(title: labelText, action: nil)
}
// MARK: - Actions
static func action(forInteraction interaction: TSInteraction,
threadViewModel: ThreadViewModel,
currentCallThreadId: String?,
transaction: SDSAnyReadTransaction) -> Action? {
let thread = threadViewModel.threadRecord
if let errorMessage = interaction as? TSErrorMessage {
return action(forErrorMessage: errorMessage)
} else if let infoMessage = interaction as? TSInfoMessage {
return action(forInfoMessage: infoMessage, transaction: transaction)
} else if let call = interaction as? TSCall {
return action(forCall: call, thread: thread, transaction: transaction)
} else if let groupCall = interaction as? OWSGroupCallMessage {
return action(forGroupCall: groupCall,
threadViewModel: threadViewModel,
currentCallThreadId: currentCallThreadId)
} else {
owsFailDebug("Invalid interaction.")
return nil
}
}
private static func action(forErrorMessage message: TSErrorMessage) -> Action? {
switch message.errorType {
case .nonBlockingIdentityChange:
guard let address = message.recipientAddress else {
owsFailDebug("Missing address.")
return nil
}
if message.wasIdentityVerified {
return Action(title: NSLocalizedString("SYSTEM_MESSAGE_ACTION_VERIFY_SAFETY_NUMBER",
comment: "Label for button to verify a user's safety number."),
accessibilityIdentifier: "verify_safety_number",
action: .didTapPreviouslyVerifiedIdentityChange(address: address))
} else {
return Action(title: CommonStrings.learnMore,
accessibilityIdentifier: "learn_more",
action: .didTapUnverifiedIdentityChange(address: address))
}
case .wrongTrustedIdentityKey:
guard let message = message as? TSInvalidIdentityKeyErrorMessage else {
owsFailDebug("Invalid interaction.")
return nil
}
return Action(title: NSLocalizedString("SYSTEM_MESSAGE_ACTION_VERIFY_SAFETY_NUMBER",
comment: "Label for button to verify a user's safety number."),
accessibilityIdentifier: "verify_safety_number",
action: .didTapInvalidIdentityKeyErrorMessage(errorMessage: message))
case .invalidKeyException,
.missingKeyId,
.noSession,
.invalidMessage:
return Action(title: NSLocalizedString("FINGERPRINT_SHRED_KEYMATERIAL_BUTTON",
comment: "Label for button to reset a session."),
accessibilityIdentifier: "reset_session",
action: .didTapCorruptedMessage(errorMessage: message))
case .sessionRefresh:
return Action(title: CommonStrings.learnMore,
accessibilityIdentifier: "learn_more",
action: .didTapSessionRefreshMessage(errorMessage: message))
case .decryptionFailure:
return Action(title: CommonStrings.learnMore,
accessibilityIdentifier: "learn_more",
action: .didTapDeliveryIssueWarning(errorMessage: message))
case .duplicateMessage,
.invalidVersion:
return nil
case .unknownContactBlockOffer:
owsFailDebug("TSErrorMessageUnknownContactBlockOffer")
return nil
case .groupCreationFailed:
return Action(title: CommonStrings.retryButton,
accessibilityIdentifier: "retry_send_group",
action: .didTapResendGroupUpdate(errorMessage: message))
}
}
private static func action(forInfoMessage infoMessage: TSInfoMessage,
transaction: SDSAnyReadTransaction) -> Action? {
switch infoMessage.messageType {
case .userNotRegistered,
.typeSessionDidEnd:
return nil
case .typeUnsupportedMessage:
// Unused.
return nil
case .addToContactsOffer:
// Unused.
owsFailDebug("TSInfoMessageAddToContactsOffer")
return nil
case .addUserToProfileWhitelistOffer:
// Unused.
owsFailDebug("TSInfoMessageAddUserToProfileWhitelistOffer")
return nil
case .addGroupToProfileWhitelistOffer:
// Unused.
owsFailDebug("TSInfoMessageAddGroupToProfileWhitelistOffer")
return nil
case .typeGroupUpdate:
guard let newGroupModel = infoMessage.newGroupModel else {
return nil
}
if newGroupModel.wasJustCreatedByLocalUserV2 {
return Action(title: NSLocalizedString("GROUPS_INVITE_FRIENDS_BUTTON",
comment: "Label for 'invite friends to group' button."),
accessibilityIdentifier: "group_invite_friends",
action: .didTapGroupInviteLinkPromotion(groupModel: newGroupModel))
}
guard let oldGroupModel = infoMessage.oldGroupModel else {
return nil
}
guard let groupUpdates = infoMessage.groupUpdateItems(transaction: transaction),
!groupUpdates.isEmpty else {
return nil
}
for groupUpdate in groupUpdates {
switch groupUpdate.type {
case .groupMigrated:
return Action(
title: CommonStrings.learnMore,
accessibilityIdentifier: "group_migration_learn_more",
action: .didTapShowGroupMigrationLearnMoreActionSheet(
infoMessage: infoMessage,
oldGroupModel: oldGroupModel,
newGroupModel: newGroupModel
)
)
case .groupDescriptionUpdated:
return Action(
title: CommonStrings.viewButton,
accessibilityIdentifier: "group_description_view",
action: .didTapViewGroupDescription(groupModel: newGroupModel)
)
default:
break
}
}
if let singleUpdateMessage = infoMessage.updateMessages?.asSingleMessage {
switch singleUpdateMessage {
case .sequenceOfInviteLinkRequestAndCancels(_, let isTail):
guard isTail else { return nil }
guard
let requesterAddress = infoMessage.groupUpdateSourceAddress,
let requesterUuid = requesterAddress.uuid
else {
owsFailDebug("Missing parameters for join request sequence")
return nil
}
guard
let mostRecentGroupModel = TSGroupThread.fetch(
groupId: newGroupModel.groupId,
transaction: transaction
)?.groupModel as? TSGroupModelV2
else {
owsFailDebug("Missing group thread for join request sequence")
return nil
}
// Only show the option to ban if we are an admin, and they are
// not already banned. We want to use the most up-to-date group
// model here instead of the one on the info message, since
// group state may have changed since that message.
guard
mostRecentGroupModel.groupMembership.isLocalUserFullMemberAndAdministrator,
!mostRecentGroupModel.groupMembership.isBannedMember(requesterUuid)
else {
return nil
}
return Action(
title: OWSLocalizedString(
"GROUPS_BLOCK_REQUEST_BUTTON",
comment: "Label for button that lets the user block a request to join the group."
),
accessibilityIdentifier: "block_join_request_button",
action: .didTapBlockRequest(
groupModel: mostRecentGroupModel,
requesterName: contactsManager.shortDisplayName(
for: requesterAddress,
transaction: transaction
),
requesterUuid: requesterUuid
)
)
}
} else {
let newlyRequestingMembers = newGroupModel.groupMembership.requestingMembers
.subtracting(oldGroupModel.groupMembership.requestingMembers)
guard !newlyRequestingMembers.isEmpty else {
return nil
}
let title = (newlyRequestingMembers.count > 1
? NSLocalizedString("GROUPS_VIEW_REQUESTS_BUTTON",
comment: "Label for button that lets the user view the requests to join the group.")
: NSLocalizedString("GROUPS_VIEW_REQUEST_BUTTON",
comment: "Label for button that lets the user view the request to join the group."))
return Action(
title: title,
accessibilityIdentifier: "show_group_requests_button",
action: .didTapShowConversationSettingsAndShowMemberRequests
)
}
case .typeGroupQuit:
return nil
case .unknownProtocolVersion:
guard let message = infoMessage as? OWSUnknownProtocolVersionMessage else {
owsFailDebug("Unexpected message type.")
return nil
}
guard message.isProtocolVersionUnknown else {
return nil
}
return Action(title: NSLocalizedString("UNKNOWN_PROTOCOL_VERSION_UPGRADE_BUTTON",
comment: "Label for button that lets users upgrade the app."),
accessibilityIdentifier: "show_upgrade_app_ui",
action: .didTapShowUpgradeAppUI)
case .typeDisappearingMessagesUpdate,
.verificationStateChange,
.userJoinedSignal,
.syncedThread:
return nil
case .profileUpdate:
guard let profileChangeAddress = infoMessage.profileChangeAddress else {
owsFailDebug("Missing profileChangeAddress.")
return nil
}
guard let profileChangeNewNameComponents = infoMessage.profileChangeNewNameComponents else {
return nil
}
guard Self.contactsManager.isSystemContact(address: profileChangeAddress,
transaction: transaction) else {
return nil
}
let systemContactName = Self.contactsManagerImpl.nameFromSystemContacts(for: profileChangeAddress,
transaction: transaction)
let newProfileName = OWSFormat.formatNameComponents(profileChangeNewNameComponents)
let currentProfileName = Self.profileManager.fullName(for: profileChangeAddress,
transaction: transaction)
// Don't show the update contact button if the system contact name
// is already equivalent to the new profile name.
guard systemContactName != newProfileName else {
return nil
}
// If the new profile name is not the current profile name, it's no
// longer relevant to ask you to update your contact.
guard currentProfileName != newProfileName else {
return nil
}
return Action(title: NSLocalizedString("UPDATE_CONTACT_ACTION", comment: "Action sheet item"),
accessibilityIdentifier: "update_contact",
action: .didTapUpdateSystemContact(address: profileChangeAddress,
newNameComponents: profileChangeNewNameComponents))
case .phoneNumberChange:
guard let uuidString = infoMessage.infoMessageUserInfo?[.changePhoneNumberUuid] as? String,
let uuid = UUID(uuidString: uuidString),
let phoneNumberOld = infoMessage.infoMessageUserInfo?[.changePhoneNumberOld] as? String,
let phoneNumberNew = infoMessage.infoMessageUserInfo?[.changePhoneNumberNew] as? String else {
owsFailDebug("Invalid info message.")
return nil
}
// Only show the update contact action if this user was previously a contact.
guard let existingContact = contactsManagerImpl.contact(forPhoneNumber: phoneNumberOld, transaction: transaction) else {
return nil
}
// Make sure the contact hasn't already had the new number added.
guard contactsManagerImpl.contact(forPhoneNumber: phoneNumberNew, transaction: transaction) != existingContact else {
return nil
}
return Action(title: NSLocalizedString("UPDATE_CONTACT_ACTION", comment: "Action sheet item"),
accessibilityIdentifier: "update_contact",
action: .didTapPhoneNumberChange(uuid: uuid,
phoneNumberOld: phoneNumberOld,
phoneNumberNew: phoneNumberNew))
}
}
private static func action(forCall call: TSCall,
thread: TSThread,
transaction: SDSAnyReadTransaction) -> Action? {
// TODO: Respect -canCall from ConversationViewController
let hasPendingMessageRequest = {
thread.hasPendingMessageRequest(transaction: transaction.unwrapGrdbRead)
}
switch call.callType {
case .incoming,
.incomingMissed,
.incomingMissedBecauseOfChangedIdentity,
.incomingMissedBecauseOfDoNotDisturb,
.incomingDeclined,
.incomingAnsweredElsewhere,
.incomingDeclinedElsewhere,
.incomingBusyElsewhere:
guard !hasPendingMessageRequest() else {
return nil
}
// TODO: cvc_didTapGroupCall?
return Action(title: NSLocalizedString("CALLBACK_BUTTON_TITLE", comment: "notification action"),
accessibilityIdentifier: "call_back",
action: .didTapIndividualCall(call: call))
case .outgoing,
.outgoingMissed:
guard !hasPendingMessageRequest() else {
return nil
}
// TODO: cvc_didTapGroupCall?
return Action(title: NSLocalizedString("CALL_AGAIN_BUTTON_TITLE",
comment: "Label for button that lets users call a contact again."),
accessibilityIdentifier: "call_again",
action: .didTapIndividualCall(call: call))
case .outgoingIncomplete,
.incomingIncomplete:
return nil
@unknown default:
owsFailDebug("Unknown value.")
return nil
}
}
private static func action(forGroupCall groupCallMessage: OWSGroupCallMessage,
threadViewModel: ThreadViewModel,
currentCallThreadId: String?) -> Action? {
let thread = threadViewModel.threadRecord
// Assume the current thread supports calling if we have no delegate. This ensures we always
// overestimate cell measurement in cases where the current thread doesn't support calling.
let isCallingSupported = ConversationViewController.canCall(threadViewModel: threadViewModel)
let isCallActive = (!groupCallMessage.hasEnded && !groupCallMessage.joinedMemberAddresses.isEmpty)
guard isCallingSupported, isCallActive else {
return nil
}
// TODO: We need to touch thread whenever current call changes.
let isCurrentCallForThread = currentCallThreadId == thread.uniqueId
let joinTitle = NSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call")
let returnTitle = NSLocalizedString("CALL_RETURN_BUTTON", comment: "Button to return to the current call")
let title = isCurrentCallForThread ? returnTitle : joinTitle
return Action(title: title,
accessibilityIdentifier: "group_call_button",
action: .didTapGroupCall)
}
}