// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI protocol MessageRequestDelegate: AnyObject { func messageRequestViewDidTapBlock() func messageRequestViewDidTapDelete() func messageRequestViewDidTapAccept(mode: MessageRequestMode, unblockThread: Bool, unhideRecipient: Bool) func messageRequestViewDidTapUnblock(mode: MessageRequestMode) func messageRequestViewDidTapReport() func messageRequestViewDidTapLearnMore() } // MARK: - public enum MessageRequestMode: UInt { case none case contactOrGroupRequest case groupInviteRequest } // MARK: - public struct MessageRequestType: Equatable { let isGroupV1Thread: Bool let isGroupV2Thread: Bool let isThreadBlocked: Bool let hasSentMessages: Bool let isThreadFromHiddenRecipient: Bool let hasReportedSpam: Bool let isLocalUserInvitedMember: Bool let showReviewRequestsCarefullyWarning: Bool } // MARK: - class MessageRequestView: ConversationBottomPanelView { enum LocalizedStrings { static let block = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_BLOCK_BUTTON", comment: "A button used to block a user on an incoming message request.", ) static let unblock = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_UNBLOCK_BUTTON", comment: "A button used to unlock a blocked conversation.", ) static let delete = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_DELETE_BUTTON", comment: "incoming message request button text which deletes a conversation", ) static let report = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_REPORT_BUTTON", comment: "incoming message request button text which reports a conversation as spam", ) static let accept = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_ACCEPT_BUTTON", comment: "A button used to accept a user on an incoming message request.", ) static let `continue` = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_CONTINUE_BUTTON", comment: "A button used to continue a conversation and share your profile.", ) } private let thread: TSThread private let mode: MessageRequestMode private let messageRequestType: MessageRequestType private var isGroupV1Thread: Bool { messageRequestType.isGroupV1Thread } private var isGroupV2Thread: Bool { messageRequestType.isGroupV2Thread } private var isThreadBlocked: Bool { messageRequestType.isThreadBlocked } private var hasSentMessages: Bool { messageRequestType.hasSentMessages } private var isThreadFromHiddenRecipient: Bool { messageRequestType.isThreadFromHiddenRecipient } private var hasReportedSpam: Bool { messageRequestType.hasReportedSpam } private var showReviewRequestsCarefullyWarning: Bool { messageRequestType.showReviewRequestsCarefullyWarning } weak var delegate: MessageRequestDelegate? // MARK: - ConversationBottomPanelView override var useGlassPanel: Bool { false } init(threadViewModel: ThreadViewModel) { let thread = threadViewModel.threadRecord self.thread = thread self.messageRequestType = SSKEnvironment.shared.databaseStorageRef.read { transaction in Self.messageRequestType(forThread: thread, transaction: transaction) } if let groupThread = thread as? TSGroupThread, groupThread.isGroupV2Thread { self.mode = ( groupThread.groupModel.groupMembership.isLocalUserInvitedMember ? .groupInviteRequest : .contactOrGroupRequest, ) } else { self.mode = .contactOrGroupRequest } super.init(frame: .zero) let arrangedSubviews: [UIView] = { switch mode { case .none: owsFailDebug("Invalid mode.") return [] case .contactOrGroupRequest: return [ prepareMessageRequestPrompt(), prepareMessageRequestButtons(), ] case .groupInviteRequest: return [ prepareGroupV2InvitePrompt(), prepareGroupV2InviteButtons(), ] } }() let stackView = UIStackView(arrangedSubviews: arrangedSubviews) stackView.axis = .vertical stackView.spacing = 16 stackView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(stackView) addConstraints([ stackView.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor), stackView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), ]) } static func messageRequestType( forThread thread: TSThread, transaction: DBReadTransaction, ) -> MessageRequestType { let isGroupV1Thread = thread.isGroupV1Thread let isGroupV2Thread = thread.isGroupV2Thread let isThreadBlocked = SSKEnvironment.shared.blockingManagerRef.isThreadBlocked(thread, transaction: transaction) var isThreadFromHiddenRecipient = false if let thread = thread as? TSContactThread { isThreadFromHiddenRecipient = DependenciesBridge.shared.recipientHidingManager.isHiddenAddress( thread.contactAddress, tx: transaction, ) } let finder = InteractionFinder(threadUniqueId: thread.uniqueId) let hasSentMessages = finder.existsOutgoingMessage(transaction: transaction) let hasReportedSpam = finder.hasUserReportedSpam(transaction: transaction) var isLocalUserInvitedMember = false if let groupThread = thread as? TSGroupThread, groupThread.groupModel.groupMembership.isLocalUserInvitedMember { isLocalUserInvitedMember = true } var showReviewRequestsCarefullyWarning = false if let contactThread = thread as? TSContactThread { if isThreadBlocked || isThreadFromHiddenRecipient || hasSentMessages { showReviewRequestsCarefullyWarning = false } else { let displayName = SSKEnvironment.shared.contactManagerRef.displayName( for: contactThread.contactAddress, tx: transaction, ) switch displayName { case .nickname: showReviewRequestsCarefullyWarning = false default: showReviewRequestsCarefullyWarning = true } } } else { showReviewRequestsCarefullyWarning = isLocalUserInvitedMember } return MessageRequestType( isGroupV1Thread: isGroupV1Thread, isGroupV2Thread: isGroupV2Thread, isThreadBlocked: isThreadBlocked, hasSentMessages: hasSentMessages, isThreadFromHiddenRecipient: isThreadFromHiddenRecipient, hasReportedSpam: hasReportedSpam, isLocalUserInvitedMember: isLocalUserInvitedMember, showReviewRequestsCarefullyWarning: showReviewRequestsCarefullyWarning, ) } required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Message Request // This is used for: // // * Contact threads // * v1 groups // * v2 groups if user does not have a pending invite. func prepareMessageRequestPrompt() -> UITextView { if thread.isGroupThread { let string: String var appendLearnMoreLink = false if thread.isGroupV1Thread { if isThreadBlocked { string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_BLOCKED_GROUP_PROMPT", comment: "A prompt notifying that the user must unblock this group to continue.", ) } else if hasSentMessages { string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_EXISTING_GROUP_PROMPT", comment: "A prompt notifying that the user must share their profile with this group.", ) appendLearnMoreLink = true } else { string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_NEW_GROUP_PROMPT", comment: "A prompt asking if the user wants to accept a group invite.", ) } } else { owsAssertDebug(thread.isGroupV2Thread) if isThreadBlocked { string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_BLOCKED_GROUP_PROMPT_V2", comment: "A prompt notifying that the user must unblock this group to continue.", ) } else { string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_NEW_GROUP_PROMPT_V2", comment: "A prompt asking if the user wants to accept a group invite.", ) } } return prepareTextView( attributedString: NSAttributedString(string: string, attributes: [ .font: UIFont.dynamicTypeSubheadlineClamped, .foregroundColor: UIColor.Signal.label, ]), appendLearnMoreLink: appendLearnMoreLink, ) } else if let thread = thread as? TSContactThread { let formatString: String var appendLearnMoreLink = false if isThreadBlocked { formatString = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_BLOCKED_CONTACT_PROMPT_FORMAT", comment: "A prompt notifying that the user must unblock this conversation to continue. Embeds {{contact name}}.", ) } else if isThreadFromHiddenRecipient { formatString = OWSLocalizedString("MESSAGE_REQUEST_VIEW_REMOVED_CONTACT_PROMPT_FORMAT", comment: "A prompt asking if the user wants to accept a conversation invite from a person whom they previously removed. Embeds {{contact name}}.") } else if hasSentMessages { formatString = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_EXISTING_CONTACT_PROMPT_FORMAT", comment: "A prompt notifying that the user must share their profile with this conversation. Embeds {{contact name}}.", ) appendLearnMoreLink = true } else { let attrString = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_NEW_CONTACT_PROMPT", comment: "A prompt asking if the user wants to accept a conversation invite.", ) return preparePromptTextView(prompt: attrString) } let shortName = SSKEnvironment.shared.databaseStorageRef.read { transaction in return SSKEnvironment.shared.contactManagerRef.displayName(for: thread.contactAddress, tx: transaction).resolvedValue(useShortNameIfAvailable: true) } return preparePromptTextView( formatString: formatString, embeddedString: shortName, appendLearnMoreLink: appendLearnMoreLink, ) } else { owsFailDebug("unexpected thread type") return UITextView() } } // This is used for: // // * Contact threads // * v1 groups // * v2 groups if user does not have a pending invite. func prepareMessageRequestButtons() -> UIStackView { let mode = self.mode var buttons = [UIView]() if isThreadBlocked { buttons.append( prepareButton(title: LocalizedStrings.delete, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapDelete() }, ) if !hasReportedSpam { buttons.append( prepareButton(title: LocalizedStrings.report, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapReport() }, ) } buttons.append( prepareButton(title: LocalizedStrings.unblock) { [weak self] in self?.delegate?.messageRequestViewDidTapUnblock(mode: mode) }, ) } else if isThreadFromHiddenRecipient { buttons.append( prepareButton(title: LocalizedStrings.block, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapBlock() }, ) if !hasReportedSpam { buttons.append( prepareButton(title: LocalizedStrings.report, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapReport() }, ) } else { buttons.append( prepareButton(title: LocalizedStrings.delete, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapDelete() }, ) } buttons.append( prepareButton(title: LocalizedStrings.accept) { [weak self] in self?.delegate?.messageRequestViewDidTapAccept(mode: mode, unblockThread: false, unhideRecipient: true) }, ) } else if hasSentMessages { buttons.append( prepareButton(title: LocalizedStrings.block, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapBlock() }, ) if !hasReportedSpam { buttons.append( prepareButton(title: LocalizedStrings.report, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapReport() }, ) } else { buttons.append( prepareButton(title: LocalizedStrings.delete, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapDelete() }, ) } buttons.append( prepareButton(title: LocalizedStrings.continue) { [weak self] in // This is the same action as accepting the message request, but displays // with slightly different visuals if the user has already been messaging // this user in the past but didn't share their profile. self?.delegate?.messageRequestViewDidTapAccept(mode: mode, unblockThread: false, unhideRecipient: false) }, ) } else { buttons.append( prepareButton(title: LocalizedStrings.block, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapBlock() }, ) if !hasReportedSpam { buttons.append( prepareButton(title: LocalizedStrings.report, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapReport() }, ) } else { buttons.append( prepareButton(title: LocalizedStrings.delete, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapDelete() }, ) } buttons.append( prepareButton(title: LocalizedStrings.accept) { [weak self] in self?.delegate?.messageRequestViewDidTapAccept(mode: mode, unblockThread: false, unhideRecipient: false) }, ) } return prepareButtonStack(buttons) } // MARK: - Group V2 Invites func prepareGroupV2InvitePrompt() -> UITextView { let string = OWSLocalizedString( "MESSAGE_REQUEST_VIEW_NEW_GROUP_PROMPT", comment: "A prompt asking if the user wants to accept a group invite.", ) let centered = NSMutableParagraphStyle() centered.alignment = .center centered.paragraphSpacingBefore = 8 return prepareTextView( attributedString: NSAttributedString(string: string, attributes: [ .font: UIFont.dynamicTypeSubheadlineClamped, .foregroundColor: UIColor.Signal.label, .paragraphStyle: centered, ]), appendLearnMoreLink: false, ) } func prepareGroupV2InviteButtons() -> UIStackView { let mode = self.mode var buttons = [UIView]() buttons.append( prepareButton(title: LocalizedStrings.block, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapBlock() }, ) if !hasReportedSpam { buttons.append( prepareButton(title: LocalizedStrings.report, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapReport() }, ) } else { buttons.append( prepareButton(title: LocalizedStrings.delete, destructive: true) { [weak self] in self?.delegate?.messageRequestViewDidTapDelete() }, ) } buttons.append( prepareButton(title: LocalizedStrings.accept) { [weak self] in self?.delegate?.messageRequestViewDidTapAccept(mode: mode, unblockThread: false, unhideRecipient: false) }, ) return prepareButtonStack(buttons) } // MARK: - private func buttonConfiguration(title: String) -> UIButton.Configuration { var configuration: UIButton.Configuration if #available(iOS 26, *) { configuration = .prominentGlass() configuration.baseForegroundColor = .Signal.label } else { configuration = .plain() configuration.baseForegroundColor = .Signal.accent } configuration.titleAlignment = .center configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped) configuration.baseBackgroundColor = .clear if #available(iOS 26, *) { configuration.cornerStyle = .capsule } configuration.title = title configuration.contentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 8) return configuration } private func prepareButton( title: String, destructive: Bool = false, actionBlock: @escaping () -> Void, ) -> UIButton { let button = UIButton( configuration: buttonConfiguration(title: title), primaryAction: UIAction { _ in actionBlock() }, ) if destructive { button.configuration?.baseForegroundColor = .Signal.red } return button } private func preparePromptTextView(prompt: String) -> UITextView { let centered = NSMutableParagraphStyle() centered.alignment = .center if showReviewRequestsCarefullyWarning { centered.paragraphSpacingBefore = 8 } let defaultAttributes: AttributedFormatArg.Attributes = [ .font: UIFont.dynamicTypeSubheadlineClamped, .foregroundColor: UIColor.Signal.label, .paragraphStyle: centered, ] let attrString = NSAttributedString(string: prompt, attributes: defaultAttributes) return prepareTextView(attributedString: attrString, appendLearnMoreLink: false) } private func preparePromptTextView(formatString: String, embeddedString: String, appendLearnMoreLink: Bool) -> UITextView { let centered = NSMutableParagraphStyle() centered.alignment = .center let defaultAttributes: AttributedFormatArg.Attributes = [ .font: UIFont.dynamicTypeSubheadlineClamped, .foregroundColor: UIColor.Signal.label, .paragraphStyle: centered, ] let attributesForEmbedded: AttributedFormatArg.Attributes = [ .font: UIFont.dynamicTypeSubheadlineClamped.semibold(), .foregroundColor: UIColor.Signal.label, ] let attributedString = NSAttributedString.make( fromFormat: formatString, attributedFormatArgs: [.string(embeddedString, attributes: attributesForEmbedded)], defaultAttributes: defaultAttributes, ) return prepareTextView(attributedString: attributedString, appendLearnMoreLink: appendLearnMoreLink) } private func prepareTextView( attributedString: NSAttributedString, appendLearnMoreLink: Bool, ) -> UITextView { let textView = LinkingTextView() if appendLearnMoreLink { textView.attributedText = .composed(of: [ attributedString, " ", CommonStrings.learnMore.styled( with: .link(URL.Support.profilesAndMessageRequests), .font(.dynamicTypeSubheadlineClamped), ), ]) .styled(with: .alignment(.center)) } else if showReviewRequestsCarefullyWarning { let fullText = NSMutableAttributedString() let centered = NSMutableParagraphStyle() centered.alignment = .center let reviewWarningAttributes: AttributedFormatArg.Attributes = [ .font: UIFont.dynamicTypeSubheadlineClamped.semibold(), .foregroundColor: UIColor.Signal.warningLabel, .paragraphStyle: centered, ] let warningIcon = SignalSymbol.errorTriangle.attributedString( dynamicTypeBaseSize: UIFont.dynamicTypeSubheadlineClamped.pointSize, attributes: [.foregroundColor: UIColor.Signal.warningLabel], ) let warningLabel = warningIcon + " " + NSAttributedString( string: OWSLocalizedString("SYSTEM_MESSAGE_UNKNOWN_THREAD_REVIEW_CAREFULLY_WARNING", comment: "Indicator warning about an unknown contact thread") + "\n", attributes: reviewWarningAttributes, ) fullText.append(warningLabel) fullText.append(attributedString) textView.attributedText = fullText.styled(with: .alignment(.center)) } else { textView.attributedText = attributedString.styled(with: .alignment(.center)) } return textView } private func prepareButtonStack(_ buttons: [UIView]) -> UIStackView { let buttonsStack = UIStackView(arrangedSubviews: buttons) buttonsStack.spacing = 11.5 buttonsStack.distribution = .fillEqually return buttonsStack } }