910 lines
39 KiB
Swift
910 lines
39 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objc
|
|
public class CVComponentSystemMessage: CVComponentBase, CVRootComponent {
|
|
|
|
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 configure(cellView: UIView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
cellSelection: CVCellSelection,
|
|
swipeToReplyState: CVSwipeToReplyState,
|
|
componentView: CVComponentView) {
|
|
|
|
configureForRendering(componentView: componentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate)
|
|
|
|
let rootView = componentView.rootView
|
|
if rootView.superview == nil {
|
|
owsAssertDebug(cellView.layoutMargins == .zero)
|
|
owsAssertDebug(cellView.subviews.isEmpty)
|
|
|
|
cellView.addSubview(rootView)
|
|
cellView.layoutMargins = cellLayoutMargins
|
|
rootView.autoPinEdgesToSuperviewMargins()
|
|
}
|
|
}
|
|
|
|
private var cellLayoutMargins: UIEdgeInsets {
|
|
UIEdgeInsets(top: 0,
|
|
leading: conversationStyle.fullWidthGutterLeading + 20,
|
|
bottom: 0,
|
|
trailing: conversationStyle.fullWidthGutterTrailing + 20)
|
|
}
|
|
|
|
private var outerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .horizontal,
|
|
alignment: .fill,
|
|
spacing: ConversationStyle.messageStackSpacing,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private var vStackConfig: CVStackViewConfig {
|
|
let layoutMargins = UIEdgeInsets(hMargin: 10, vMargin: 8)
|
|
return CVStackViewConfig(axis: .vertical,
|
|
alignment: .center,
|
|
spacing: 12,
|
|
layoutMargins: layoutMargins)
|
|
}
|
|
|
|
private var hStackConfig: CVStackViewConfig {
|
|
return CVStackViewConfig(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
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 outerStack = componentView.outerStack
|
|
let vStackView = componentView.vStackView
|
|
let hStackView = componentView.hStackView
|
|
let selectionView = componentView.selectionView
|
|
let titleLabel = componentView.titleLabel
|
|
|
|
if isShowingSelectionUI {
|
|
selectionView.isSelected = componentDelegate.cvc_isMessageSelected(interaction)
|
|
}
|
|
selectionView.isHiddenInStackView = !isShowingSelectionUI
|
|
|
|
titleLabelConfig.applyForRendering(label: titleLabel)
|
|
|
|
let isReusing = componentView.rootView.superview != nil
|
|
if !isReusing {
|
|
outerStack.apply(config: outerStackConfig)
|
|
vStackView.apply(config: vStackConfig)
|
|
hStackView.apply(config: hStackConfig)
|
|
|
|
outerStack.addArrangedSubview(selectionView)
|
|
outerStack.addArrangedSubview(hStackView)
|
|
|
|
let leadingSpacer = UIView.hStretchingSpacer()
|
|
let trailingSpacer = UIView.hStretchingSpacer()
|
|
|
|
hStackView.addArrangedSubview(leadingSpacer)
|
|
hStackView.addArrangedSubview(vStackView)
|
|
hStackView.addArrangedSubview(trailingSpacer)
|
|
|
|
leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer)
|
|
|
|
vStackView.addArrangedSubview(titleLabel)
|
|
}
|
|
|
|
let themeHasChanged = conversationStyle.isDarkThemeEnabled != componentView.isDarkThemeEnabled
|
|
componentView.isDarkThemeEnabled = conversationStyle.isDarkThemeEnabled
|
|
|
|
let hasWallpaper = conversationStyle.hasWallpaper
|
|
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
|
|
componentView.hasWallpaper = hasWallpaper
|
|
|
|
let isFirstInCluster = itemModel.itemViewState.isFirstInCluster
|
|
let isLastInCluster = itemModel.itemViewState.isLastInCluster
|
|
let hasClusteringChanges = componentView.isFirstInCluster != isFirstInCluster || componentView.isLastInCluster != isLastInCluster
|
|
componentView.isFirstInCluster = isFirstInCluster
|
|
componentView.isLastInCluster = isLastInCluster
|
|
|
|
if !isReusing || themeHasChanged || wallpaperModeHasChanged || hasClusteringChanges {
|
|
componentView.blurView?.removeFromSuperview()
|
|
componentView.blurView = nil
|
|
|
|
componentView.backgroundView?.removeFromSuperview()
|
|
componentView.backgroundView = nil
|
|
|
|
let bubbleView: UIView
|
|
|
|
if hasWallpaper {
|
|
let blurView = buildBlurView(conversationStyle: conversationStyle)
|
|
componentView.blurView = blurView
|
|
bubbleView = blurView
|
|
} else {
|
|
let backgroundView = UIView()
|
|
componentView.backgroundView = backgroundView
|
|
bubbleView = backgroundView
|
|
backgroundView.backgroundColor = Theme.backgroundColor
|
|
}
|
|
|
|
vStackView.insertSubview(bubbleView, at: 0)
|
|
bubbleView.autoPinVerticalEdges(toEdgesOf: hStackView)
|
|
|
|
if isFirstInCluster && isLastInCluster {
|
|
bubbleView.autoPinHorizontalEdges(toEdgesOf: vStackView)
|
|
|
|
bubbleView.layer.cornerRadius = 8
|
|
bubbleView.clipsToBounds = true
|
|
} else {
|
|
bubbleView.autoPinHorizontalEdges(toEdgesOf: hStackView)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
if let action = action, !itemViewState.shouldCollapseSystemMessageAction {
|
|
let button = OWSButton(title: action.title) {}
|
|
componentView.button = button
|
|
button.accessibilityIdentifier = action.accessibilityIdentifier
|
|
button.titleLabel?.textAlignment = .center
|
|
button.titleLabel?.font = UIFont.ows_dynamicTypeFootnote.ows_semibold
|
|
if nil != interaction as? OWSGroupCallMessage {
|
|
let buttonTitleColor: UIColor = Theme.isDarkThemeEnabled ? .ows_whiteAlpha90 : .white
|
|
button.setTitleColor(buttonTitleColor, for: .normal)
|
|
button.backgroundColor = UIColor.ows_accentGreen
|
|
} else {
|
|
button.setTitleColor(Theme.conversationButtonTextColor, for: .normal)
|
|
if isDarkThemeEnabled && hasWallpaper {
|
|
button.backgroundColor = .ows_gray65
|
|
} else {
|
|
button.backgroundColor = Theme.conversationButtonBackgroundColor
|
|
}
|
|
}
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 3, leading: 12, bottom: 3, trailing: 12)
|
|
button.layer.cornerRadius = buttonHeight / 2
|
|
button.autoSetDimension(.height, toSize: buttonHeight)
|
|
button.isUserInteractionEnabled = false
|
|
vStackView.addArrangedSubview(button)
|
|
}
|
|
}
|
|
|
|
private var titleLabelConfig: CVLabelConfig {
|
|
CVLabelConfig(attributedText: systemMessage.title,
|
|
font: Self.titleLabelFont,
|
|
textColor: systemMessage.titleColor,
|
|
numberOfLines: 0,
|
|
lineBreakMode: .byWordWrapping,
|
|
textAlignment: .center)
|
|
}
|
|
|
|
private static var titleLabelFont: UIFont {
|
|
UIFont.ows_dynamicTypeFootnote
|
|
}
|
|
|
|
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
owsAssertDebug(maxWidth > 0)
|
|
|
|
var availableWidth = max(0, maxWidth - cellLayoutMargins.totalWidth)
|
|
if isShowingSelectionUI {
|
|
// Account for selection UI when doing measurement.
|
|
availableWidth -= ConversationStyle.selectionViewWidth + outerStackConfig.spacing
|
|
}
|
|
|
|
var height: CGFloat = 0
|
|
|
|
let titleSize = CVText.measureLabel(config: titleLabelConfig,
|
|
maxWidth: availableWidth)
|
|
height += titleSize.height
|
|
height += cellLayoutMargins.totalHeight
|
|
if action != nil, !itemViewState.shouldCollapseSystemMessageAction {
|
|
height += buttonHeight + vStackConfig.spacing
|
|
}
|
|
height += vStackConfig.layoutMargins.totalHeight
|
|
|
|
// Full width.
|
|
return CGSize(width: maxWidth, height: height).ceil
|
|
}
|
|
|
|
// Should this reflect dynamic type used in the button?
|
|
private let buttonHeight: CGFloat = 28
|
|
|
|
// 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
|
|
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
|
|
if componentDelegate.cvc_isMessageSelected(interaction) {
|
|
selectionView.isSelected = false
|
|
componentDelegate.cvc_didDeselectViewItem(itemViewModel)
|
|
} else {
|
|
selectionView.isSelected = true
|
|
componentDelegate.cvc_didSelectViewItem(itemViewModel)
|
|
}
|
|
// Suppress other tap handling during selection mode.
|
|
return true
|
|
}
|
|
|
|
if let action = systemMessage.action {
|
|
let rootView = componentView.rootView
|
|
if rootView.containsGestureLocation(sender) {
|
|
action.action.perform(delegate: componentDelegate)
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public override func findLongPressHandler(sender: UILongPressGestureRecognizer,
|
|
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.
|
|
@objc
|
|
public class CVComponentViewSystemMessage: NSObject, CVComponentView {
|
|
|
|
fileprivate let outerStack = OWSStackView(name: "systemMessage.outerStack")
|
|
fileprivate let vStackView = OWSStackView(name: "systemMessage.vStackView")
|
|
fileprivate let hStackView = OWSStackView(name: "systemMessage.hStackView")
|
|
fileprivate let titleLabel = UILabel()
|
|
fileprivate let selectionView = MessageSelectionView()
|
|
|
|
fileprivate var blurView: UIVisualEffectView?
|
|
fileprivate var backgroundView: UIView?
|
|
|
|
fileprivate 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 rootView: UIView {
|
|
outerStack
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
override required init() {
|
|
super.init()
|
|
|
|
titleLabel.textAlignment = .center
|
|
}
|
|
|
|
public func setIsCellVisible(_ isCellVisible: Bool) {}
|
|
|
|
public func reset() {
|
|
owsAssertDebug(isDedicatedCellView)
|
|
|
|
if !isDedicatedCellView {
|
|
outerStack.reset()
|
|
vStackView.reset()
|
|
hStackView.reset()
|
|
|
|
blurView?.removeFromSuperview()
|
|
blurView = nil
|
|
|
|
backgroundView?.removeFromSuperview()
|
|
backgroundView = nil
|
|
|
|
hasWallpaper = false
|
|
isDarkThemeEnabled = false
|
|
isFirstInCluster = false
|
|
isLastInCluster = false
|
|
}
|
|
|
|
titleLabel.text = nil
|
|
|
|
button?.removeFromSuperview()
|
|
button = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension CVComponentSystemMessage {
|
|
|
|
static func buildComponentState(interaction: TSInteraction,
|
|
threadViewModel: ThreadViewModel,
|
|
currentCallThreadId: String?,
|
|
transaction: SDSAnyReadTransaction) -> CVComponentState.SystemMessage {
|
|
|
|
let title = Self.title(forInteraction: interaction, transaction: transaction)
|
|
let titleColor = Self.textColor(forInteraction: interaction)
|
|
let action = Self.action(forInteraction: interaction,
|
|
threadViewModel: threadViewModel,
|
|
currentCallThreadId: currentCallThreadId,
|
|
transaction: transaction)
|
|
|
|
return CVComponentState.SystemMessage(title: title, titleColor: titleColor, action: action)
|
|
}
|
|
|
|
private static func title(forInteraction interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction) -> NSAttributedString {
|
|
|
|
let font = Self.titleLabelFont
|
|
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, attributes: [:])
|
|
|
|
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 {
|
|
labelText.append(LocalizationNotNeeded(" · "))
|
|
labelText.append(DateUtil.formatTimestamp(asDate: interaction.timestamp))
|
|
labelText.append(LocalizationNotNeeded(" "))
|
|
labelText.append(DateUtil.formatTimestamp(asTime: 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 func textColor(forInteraction interaction: TSInteraction) -> UIColor {
|
|
if let call = interaction as? TSCall {
|
|
switch call.callType {
|
|
case .incomingMissed,
|
|
.incomingMissedBecauseOfChangedIdentity,
|
|
.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 Theme.secondaryTextAndIconColor
|
|
}
|
|
} else {
|
|
return Theme.secondaryTextAndIconColor
|
|
}
|
|
}
|
|
|
|
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 .invalidKeyException,
|
|
.missingKeyId,
|
|
.noSession,
|
|
.invalidMessage,
|
|
.duplicateMessage,
|
|
.invalidVersion,
|
|
.unknownContactBlockOffer,
|
|
.groupCreationFailed:
|
|
return nil
|
|
@unknown default:
|
|
owsFailDebug("Unknown value.")
|
|
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)
|
|
@unknown default:
|
|
owsFailDebug("Unknown value.")
|
|
return nil
|
|
}
|
|
} 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,
|
|
.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:
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
static func action(forInteraction interaction: TSInteraction,
|
|
threadViewModel: ThreadViewModel,
|
|
currentCallThreadId: String?,
|
|
transaction: SDSAnyReadTransaction) -> CVMessageAction? {
|
|
|
|
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) -> CVMessageAction? {
|
|
switch message.errorType {
|
|
case .nonBlockingIdentityChange:
|
|
guard let address = message.recipientAddress else {
|
|
owsFailDebug("Missing address.")
|
|
return nil
|
|
}
|
|
|
|
return CVMessageAction(title: NSLocalizedString("SYSTEM_MESSAGE_ACTION_VERIFY_SAFETY_NUMBER",
|
|
comment: "Label for button to verify a user's safety number."),
|
|
accessibilityIdentifier: "verify_safety_number",
|
|
action: .cvc_didTapNonBlockingIdentityChange(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: .cvc_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: .cvc_didTapCorruptedMessage(errorMessage: message))
|
|
case .sessionRefresh:
|
|
return Action(title: CommonStrings.learnMore,
|
|
accessibilityIdentifier: "learn_more",
|
|
action: .cvc_didTapSessionRefreshMessage(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: .cvc_didTapResendGroupUpdate(errorMessage: message))
|
|
@unknown default:
|
|
owsFailDebug("Unknown value.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func action(forInfoMessage infoMessage: TSInfoMessage,
|
|
transaction: SDSAnyReadTransaction) -> CVMessageAction? {
|
|
|
|
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: .cvc_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 {
|
|
if groupUpdate.type == .groupMigrated {
|
|
return Action(title: CommonStrings.learnMore,
|
|
accessibilityIdentifier: "group_migration_learn_more",
|
|
action: .cvc_didTapShowGroupMigrationLearnMoreActionSheet(infoMessage: infoMessage,
|
|
oldGroupModel: oldGroupModel,
|
|
newGroupModel: newGroupModel))
|
|
}
|
|
}
|
|
|
|
var newlyRequestingMembers = Set<SignalServiceAddress>()
|
|
newlyRequestingMembers.formUnion(newGroupModel.groupMembership.requestingMembers)
|
|
newlyRequestingMembers.subtract(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: .cvc_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: .cvc_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) else {
|
|
return nil
|
|
}
|
|
let systemContactName = Self.contactsManager.nameFromSystemContacts(for: profileChangeAddress,
|
|
transaction: transaction)
|
|
let newProfileName = PersonNameComponentsFormatter.localizedString(from: profileChangeNewNameComponents,
|
|
style: .`default`,
|
|
options: [])
|
|
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: .cvc_didTapUpdateSystemContact(address: profileChangeAddress,
|
|
newNameComponents: profileChangeNewNameComponents))
|
|
@unknown default:
|
|
owsFailDebug("Unknown value.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func action(forCall call: TSCall,
|
|
thread: TSThread,
|
|
transaction: SDSAnyReadTransaction) -> CVMessageAction? {
|
|
|
|
// TODO: Respect -canCall from ConversationViewController
|
|
|
|
let hasPendingMessageRequest = {
|
|
thread.hasPendingMessageRequest(transaction: transaction.unwrapGrdbRead)
|
|
}
|
|
|
|
switch call.callType {
|
|
case .incoming,
|
|
.incomingMissed,
|
|
.incomingMissedBecauseOfChangedIdentity,
|
|
.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: .cvc_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: .cvc_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?) -> CVMessageAction? {
|
|
|
|
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: .cvc_didTapGroupCall)
|
|
}
|
|
}
|