// // 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, bottom: 0, trailing: conversationStyle.fullWidthGutterTrailing) } 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) titleLabel.accessibilityLabel = accessibilityLabel(description: titleLabelConfig.stringValue, ignoreMissingSenderName: true) 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(.spacer(withWidth: 4)) outerStack.addArrangedSubview(hStackView) outerStack.addArrangedSubview(.spacer(withWidth: 4)) 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 } // Padding around the hStack (leading and trailing side) availableWidth -= (outerStackConfig.spacing + 4) * 2 // Padding around the vStackView availableWidth -= vStackConfig.layoutMargins.totalWidth 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 } if message.wasIdentityVerified { 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)) } else { return CVMessageAction(title: CommonStrings.learnMore, accessibilityIdentifier: "learn_more", 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() 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) } }