Message request state updates

This commit is contained in:
kate-signal 2026-04-10 14:19:02 -04:00 committed by GitHub
parent 90d1aa4310
commit 1a07a9a252
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 364 additions and 218 deletions

View File

@ -84,6 +84,7 @@
04AB61C62E5E37A800405699 /* PollRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C52E5E37A400405699 /* PollRecord.swift */; };
04AB61C82E5E399700405699 /* PollOptionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C72E5E399400405699 /* PollOptionRecord.swift */; };
04AB61CA2E5E449100405699 /* PollManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C92E5E448A00405699 /* PollManagerTest.swift */; };
04AC221D2F86B9F8006CB71A /* NSAttributedStringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC221C2F86B9F2006CB71A /* NSAttributedStringExtensionTests.swift */; };
04BBBE902E259A6900E914B1 /* InactivePrimaryDeviceReminderMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BBBE8F2E259A5F00E914B1 /* InactivePrimaryDeviceReminderMegaphone.swift */; };
04BBBE922E26C92D00E914B1 /* InactivePrimaryDeviceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BBBE912E26C92300E914B1 /* InactivePrimaryDeviceStore.swift */; };
04BBBE942E26F00900E914B1 /* InactivePrimaryDeviceStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BBBE932E26F00000E914B1 /* InactivePrimaryDeviceStoreTest.swift */; };
@ -4233,6 +4234,7 @@
04AB61C52E5E37A400405699 /* PollRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRecord.swift; sourceTree = "<group>"; };
04AB61C72E5E399400405699 /* PollOptionRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionRecord.swift; sourceTree = "<group>"; };
04AB61C92E5E448A00405699 /* PollManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollManagerTest.swift; sourceTree = "<group>"; };
04AC221C2F86B9F2006CB71A /* NSAttributedStringExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedStringExtensionTests.swift; sourceTree = "<group>"; };
04B975452E43A4AA00E20364 /* BackupRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupRefreshManager.swift; sourceTree = "<group>"; };
04B975472E43BFE000E20364 /* BackupRefreshManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupRefreshManagerTest.swift; sourceTree = "<group>"; };
04BBBE8F2E259A5F00E914B1 /* InactivePrimaryDeviceReminderMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePrimaryDeviceReminderMegaphone.swift; sourceTree = "<group>"; };
@ -14362,6 +14364,7 @@
50D5E2422980B53000899660 /* LinkValidatorTest.swift */,
F94261F2289B1B5400460798 /* LRUCacheTest.swift */,
F94261FC289B1B5400460798 /* MathOWSTests.swift */,
04AC221C2F86B9F2006CB71A /* NSAttributedStringExtensionTests.swift */,
F94261E9289B1B5400460798 /* NSData+ImageTest.swift */,
D95E149C2E3D22FA00B5B70B /* ObjectRetainerTest.swift */,
F96BB60629A528BD001C18DF /* OWS2FAManagerTest.swift */,
@ -20238,6 +20241,7 @@
50CDC6592DFB92FD00824B4A /* MonitorTest.swift in Sources */,
5056B3BF2DEED72800F55320 /* MonotonicDateTest.swift in Sources */,
5079F3C22DA47554008430EC /* NotificationPreconditionTest.swift in Sources */,
04AC221D2F86B9F8006CB71A /* NSAttributedStringExtensionTests.swift in Sources */,
F9426256289B1B5500460798 /* NSData+ImageTest.swift in Sources */,
D979CC3A2AD3964E006AAC49 /* Numbers+Random.swift in Sources */,
D95E149D2E3D22FD00B5B70B /* ObjectRetainerTest.swift in Sources */,

View File

@ -552,6 +552,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer {
presentationContext: configurator.isIncoming ? .messageBubbleQuoteReplyIncoming : .messageBubbleQuoteReplyOutgoing,
lineBreakMode: .byTruncatingTail,
numberOfLines: 0,
signalSymbolRange: nil,
onTap: nil,
)
} else {
@ -852,6 +853,7 @@ public class CVQuotedMessageView: ManualStackViewWithLayer {
highlightFont: .dynamicTypeFootnote,
presentationContext: configurator.isIncoming ? .messageBubbleQuoteReplyIncoming : .messageBubbleQuoteReplyOutgoing,
maxWidth: maxLabelWidth,
signalSymbolRange: nil,
)
} else {
quotedAuthorSize = CVText.measureLabel(

View File

@ -105,6 +105,7 @@ public class CVComponentSenderName: CVComponentBase, CVComponent {
highlightFont: .dynamicTypeFootnote,
axLabelPrefix: nil, // handled separately in CVItemViewState
presentationContext: .messageBubbleRegular,
signalSymbolRange: nil,
onTap: nil,
)
} else {
@ -189,6 +190,7 @@ public class CVComponentSenderName: CVComponentBase, CVComponent {
highlightFont: .dynamicTypeFootnote,
presentationContext: .messageBubbleRegular,
maxWidth: maxWidth,
signalSymbolRange: nil,
)
} else {
labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxWidth)

View File

@ -474,8 +474,6 @@ public struct CVComponentState: Equatable {
struct ThreadDetails: Equatable {
struct SafetySection: Equatable {
/// For " Review Carefully"
let shouldShowLowTrustWarning: Bool
/// For "Profile names are not verified"
let shouldShowProfileNamesEducation: Bool
/// For phone numbers or group member count

View File

@ -188,7 +188,6 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
innerViews.append(groupDescriptionPreviewView)
}
let namesEducationLabel = componentView.profileNamesEducationLabel
let detailsButton = componentView.detailsButton
let mutualGroupsLabel = componentView.mutualGroupsLabel
let showTipsButton = componentView.showTipsButton
@ -198,14 +197,6 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
var groupInfoSubviews: [UIView] = []
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowLowTrustWarning {
let reviewCarefullyLabel = componentView.reviewCarefullyLabel
groupInfoSubviews.append(reviewCarefullyLabel)
let config = self.reviewCarefullyConfig()
config.applyForRendering(label: reviewCarefullyLabel)
groupInfoSubviewInfos.append(reviewCarefullyLabel.sizeThatFitsMaxSize.asManualSubviewInfo)
}
innerViews.append(UIView.spacer(withHeight: vSpacingSafetySection(hasWallpaper: conversationStyle.hasWallpaper)))
if conversationStyle.hasWallpaper {
@ -239,14 +230,26 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
- (hPaddingSafetySection * 2)
if safetySection.shouldShowProfileNamesEducation {
let (namesEducationString, symbolRange) = nameNotVerifiedStringAndSymbolRange()
let namesEducationLabel = CVCapsuleLabel(
attributedText: namesEducationString,
textColor: UIColor.Signal.warningLabel,
font: .dynamicTypeCallout.medium(),
highlightRange: namesEducationString.string.entireRange,
highlightFont: .dynamicTypeCallout,
axLabelPrefix: nil,
presentationContext: .nameNotVerifiedWarning,
numberOfLines: 1,
signalSymbolRange: symbolRange,
onTap: {
componentDelegate.didTapNameEducation(type: safetySection.threadType)
},
)
namesEducationLabel.textAlignment = .center
groupInfoSubviews.append(namesEducationLabel)
let config = namesEducationConfig(type: safetySection.threadType)
config.applyForRendering(button: namesEducationLabel)
namesEducationLabel.block = { [weak componentDelegate] in
componentDelegate?.didTapNameEducation(type: safetySection.threadType)
}
componentView.profileNamesEducationLabel = namesEducationLabel
let size = CVText.measureLabel(config: config, maxWidth: maxWidth)
let size = namesEducationLabel.labelSize(maxWidth: maxWidth)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
@ -398,106 +401,20 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
private static var underlineColor: UIColor { UIColor.Signal.transparentSeparator }
private static var reviewCarefullyFont: UIFont { .dynamicTypeSubheadline.semibold() }
private static var reviewCarefullyTextColor: UIColor { UIColor(rgbHex: 0xA88746) }
private func reviewCarefullyConfig() -> CVLabelConfig {
CVLabelConfig(
text: .attributedText(
.composed(of: [
NSAttributedString.with(
image: UIImage(named: "error-triangle-fill-compact")!,
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: Self.reviewCarefullyFont,
heightReference: .pointSize,
),
SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
OWSLocalizedString(
"SYSTEM_MESSAGE_UNKNOWN_THREAD_REVIEW_CAREFULLY_WARNING",
comment: "Indicator warning about an unknown contact thread",
),
]).styled(with: .alignment(.center)),
),
displayConfig: .forUnstyledText(
font: Self.reviewCarefullyFont,
textColor: Self.reviewCarefullyTextColor,
),
font: Self.reviewCarefullyFont,
textColor: Self.reviewCarefullyTextColor,
numberOfLines: 0,
)
}
private func namesEducationIcon(type: SafetyTipsType) -> UIImage {
switch type {
case .contact:
return UIImage(named: "person-questionmark-compact")!
case .group:
return UIImage(named: "group-questionmark-compact")!
}
}
private func underlinedNamesEducationString(type: SafetyTipsType) -> NSAttributedString {
let (subject, predicate): (String, String) = switch type {
case .contact:
(
private func nameNotVerifiedStringAndSymbolRange() -> (NSAttributedString, NSRange) {
let symbol = SignalSymbol.personQuestion.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCallout.pointSize)
let notVerifiedString = NSAttributedString.composed(
of: [
symbol,
SignalSymbol.LeadingCharacter.space.rawValue,
OWSLocalizedString(
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT",
comment: "Label displayed below profiles. This is the subject part of the sentence 'Profile names are not verified'. It is embedded into THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE.",
comment: "Label displayed below profiles",
),
OWSLocalizedString(
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE",
comment: "Label displayed below profiles. This is the predicate part of the sentence 'Profile names are not verified'. Embeds {{ THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT }}",
),
)
case .group:
(
OWSLocalizedString(
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT",
comment: "Label displayed below group info. This is the subject part of the sentence 'Group names are not verified'. It is embedded into THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE.",
),
OWSLocalizedString(
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE",
comment: "Label displayed below group info. This is the predicate part of the sentence 'Group names are not verified'. Embeds {{ THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT }}",
),
)
}
let formattedString = String.nonPluralLocalizedStringWithFormat(predicate, subject)
let subjectRange = NSString(string: formattedString).range(of: subject)
let attributedString = NSMutableAttributedString(string: formattedString)
attributedString.addAttributes(
[
.underlineStyle: NSUnderlineStyle.single.rawValue,
.underlineColor: Self.underlineColor,
],
range: subjectRange,
)
return attributedString
}
private func namesEducationConfig(type: SafetyTipsType) -> CVLabelConfig {
CVLabelConfig(
text: .attributedText(
.composed(of: [
NSAttributedString.with(
image: self.namesEducationIcon(type: type),
font: .dynamicTypeCallout,
centerVerticallyRelativeTo: Self.mutualGroupsFont,
heightReference: .pointSize,
),
SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
self.underlinedNamesEducationString(type: type),
]).styled(with: .alignment(.center)),
),
displayConfig: .forUnstyledText(
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
),
font: Self.mutualGroupsFont,
textColor: Self.mutualGroupsTextColor,
numberOfLines: 0,
)
return (notVerifiedString, (notVerifiedString.string as NSString).range(of: symbol.string))
}
private func mutualGroupsLabelConfig(attributedText: NSAttributedString) -> CVLabelConfig {
@ -754,14 +671,6 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
var groupInfoSubviewInfos = [ManualStackSubviewInfo]()
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowLowTrustWarning {
let reviewCarefullySize = CVText.measureLabel(
config: self.reviewCarefullyConfig(),
maxWidth: maxGroupWidth,
)
groupInfoSubviewInfos.append(reviewCarefullySize.asManualSubviewInfo)
}
innerSubviewInfos.append(CGSize(square: vSpacingSafetySection(hasWallpaper: conversationStyle.hasWallpaper)).asManualSubviewInfo)
let mutualGroupsSize: CGSize
@ -770,9 +679,15 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
}
if safetySection.shouldShowProfileNamesEducation {
let size = CVText.measureLabel(
config: self.namesEducationConfig(type: safetySection.threadType),
maxWidth: maxGroupWidth,
let (namesEducationString, symbolRange) = nameNotVerifiedStringAndSymbolRange()
let size = CVCapsuleLabel.measureLabel(
attributedText: namesEducationString,
font: .dynamicTypeCallout.medium(),
highlightRange: namesEducationString.string.entireRange,
highlightFont: .dynamicTypeCallout,
presentationContext: .nonMessageBubble,
maxWidth: maxContentWidth,
signalSymbolRange: symbolRange,
)
groupInfoSubviewInfos.append(size.asManualSubviewInfo)
}
@ -869,7 +784,8 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if
safetySection.shouldShowProfileNamesEducation,
componentView.profileNamesEducationLabel.bounds.contains(sender.location(in: componentView.profileNamesEducationLabel))
let profileNamesEducationLabel = componentView.profileNamesEducationLabel,
profileNamesEducationLabel.bounds.contains(sender.location(in: profileNamesEducationLabel))
{
componentDelegate.didTapNameEducation(type: safetySection.threadType)
return true
@ -904,8 +820,9 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
fileprivate let titleButton = CVButton()
fileprivate let bioLabel = CVLabel()
fileprivate var profileNamesEducationLabel: CVCapsuleLabel?
fileprivate let reviewCarefullyLabel = CVLabel()
fileprivate let profileNamesEducationLabel = CVButton()
fileprivate let detailsButton = CVButton()
fileprivate let mutualGroupsLabel = CVLabel()
fileprivate let showTipsButton = OWSRoundedButton()
@ -944,7 +861,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
titleButton.reset()
bioLabel.text = nil
reviewCarefullyLabel.text = nil
profileNamesEducationLabel.reset()
profileNamesEducationLabel = nil
detailsButton.reset()
mutualGroupsLabel.text = nil
groupDescriptionPreviewView.descriptionText = nil
@ -1085,7 +1002,6 @@ extension CVComponentThreadDetails {
let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustGroup(groupThread: groupThread, tx: tx)
return .init(
shouldShowLowTrustWarning: shouldShowUnknownThreadWarning,
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
detailsText: membersAttributedText,
mutualGroupsText: nil,
@ -1111,7 +1027,6 @@ extension CVComponentThreadDetails {
guard !contactThread.isNoteToSelf else {
return .init(
shouldShowLowTrustWarning: false,
shouldShowProfileNamesEducation: false,
detailsText: nil,
mutualGroupsText: OWSLocalizedString(
@ -1131,11 +1046,6 @@ extension CVComponentThreadDetails {
let isMessageRequest = contactThread.hasPendingMessageRequest(transaction: tx)
let shouldShowUnknownThreadWarning = SSKEnvironment.shared.contactManagerImplRef.isLowTrustContact(
contactThread: contactThread,
tx: tx,
)
let groupNamesFormatArg: [String] = mutualGroupNames
let formattedString: String
switch mutualGroupNames.count {
@ -1206,17 +1116,19 @@ extension CVComponentThreadDetails {
])
}()
let isPhoneContact = phoneNumberString != nil
let shouldShowProfileNamesEducation = if isPhoneContact {
false
let isSystemContact = SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: contactThread.contactAddress, transaction: tx) != nil
let shouldShowProfileNamesEducation: Bool
if isMessageRequest {
shouldShowProfileNamesEducation = true
} else if case .nickname = displayName {
false
shouldShowProfileNamesEducation = false
} else if isSystemContact {
shouldShowProfileNamesEducation = false
} else {
true
shouldShowProfileNamesEducation = true
}
return .init(
shouldShowLowTrustWarning: shouldShowUnknownThreadWarning,
shouldShowProfileNamesEducation: shouldShowProfileNamesEducation,
detailsText: phoneNumberString,
mutualGroupsText: NSAttributedString.composed(of: [

View File

@ -33,6 +33,7 @@ public struct MessageRequestType: Equatable {
let isThreadFromHiddenRecipient: Bool
let hasReportedSpam: Bool
let isLocalUserInvitedMember: Bool
let showReviewRequestsCarefullyWarning: Bool
}
// MARK: -
@ -94,6 +95,10 @@ class MessageRequestView: ConversationBottomPanelView {
messageRequestType.hasReportedSpam
}
private var showReviewRequestsCarefullyWarning: Bool {
messageRequestType.showReviewRequestsCarefullyWarning
}
weak var delegate: MessageRequestDelegate?
init(threadViewModel: ThreadViewModel) {
@ -170,6 +175,26 @@ class MessageRequestView: ConversationBottomPanelView {
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,
@ -178,6 +203,7 @@ class MessageRequestView: ConversationBottomPanelView {
isThreadFromHiddenRecipient: isThreadFromHiddenRecipient,
hasReportedSpam: hasReportedSpam,
isLocalUserInvitedMember: isLocalUserInvitedMember,
showReviewRequestsCarefullyWarning: showReviewRequestsCarefullyWarning,
)
}
@ -256,10 +282,11 @@ class MessageRequestView: ConversationBottomPanelView {
)
appendLearnMoreLink = true
} else {
formatString = OWSLocalizedString(
"MESSAGE_REQUEST_VIEW_NEW_CONTACT_PROMPT_FORMAT",
comment: "A prompt asking if the user wants to accept a conversation invite. Embeds {{contact name}}.",
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
@ -392,10 +419,15 @@ class MessageRequestView: ConversationBottomPanelView {
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: Theme.secondaryTextAndIconColor,
.paragraphStyle: centered,
]),
appendLearnMoreLink: false,
)
@ -452,6 +484,22 @@ class MessageRequestView: ConversationBottomPanelView {
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: Theme.secondaryTextAndIconColor,
.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
@ -475,7 +523,10 @@ class MessageRequestView: ConversationBottomPanelView {
return prepareTextView(attributedString: attributedString, appendLearnMoreLink: appendLearnMoreLink)
}
private func prepareTextView(attributedString: NSAttributedString, appendLearnMoreLink: Bool) -> UITextView {
private func prepareTextView(
attributedString: NSAttributedString,
appendLearnMoreLink: Bool,
) -> UITextView {
let textView = LinkingTextView()
if appendLearnMoreLink {
textView.attributedText = .composed(of: [
@ -487,6 +538,30 @@ class MessageRequestView: ConversationBottomPanelView {
),
])
.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))
}

View File

@ -19,6 +19,7 @@ class NameEducationSheet: StackSheetViewController {
UIColor.Signal.transparentSeparator
}
private static let capsuleColor = UIColor.Signal.warningLabel
private let type: SafetyTipsType
init(type: SafetyTipsType) {
@ -28,29 +29,59 @@ class NameEducationSheet: StackSheetViewController {
stackView.alignment = .fill
stackView.spacing = 12
stackView.addArrangedSubview(heroImageView)
stackView.setCustomSpacing(24, after: heroImageView)
stackView.addArrangedSubview(heroImageContainerView)
stackView.setCustomSpacing(24, after: heroImageContainerView)
stackView.addArrangedSubview(header)
stackView.setCustomSpacing(20, after: header)
let bulletPoints = self.bulletPoints.map { text in
ListPointView(text: text)
BulletPointView(text: text)
}
stackView.addArrangedSubviews(bulletPoints)
stackView.setCustomSpacing(20, after: bulletPoints.last!)
}
private lazy var heroImageView: UIImageView = {
let view = UIImageView()
view.image = switch self.type {
private lazy var heroImageContainerView: UIView = {
let imageView = UIImageView()
imageView.image = switch self.type {
case .contact:
UIImage(named: "person-questionmark-display")
.personQuestionmarkCompact
case .group:
UIImage(named: "group-questionmark-display")
.groupQuestionmarkCompact
}
view.tintColor = .label
view.contentMode = .scaleAspectFit
view.autoSetDimension(.height, toSize: 56)
return view
imageView.tintColor = Self.capsuleColor
imageView.contentMode = .scaleAspectFit
let imageInnerContainer = UIView()
imageInnerContainer.backgroundColor = Self.capsuleColor.withAlphaComponent(0.12)
imageInnerContainer.layer.cornerRadius = 24
imageInnerContainer.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageInnerContainer.widthAnchor.constraint(equalToConstant: 68),
imageInnerContainer.heightAnchor.constraint(equalToConstant: 48),
imageView.widthAnchor.constraint(equalToConstant: 32),
imageView.heightAnchor.constraint(equalToConstant: 32),
imageView.centerXAnchor.constraint(equalTo: imageInnerContainer.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: imageInnerContainer.centerYAnchor),
])
let imageOuterContainer = UIView()
imageOuterContainer.addSubview(imageInnerContainer)
imageInnerContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageInnerContainer.centerXAnchor.constraint(equalTo: imageOuterContainer.centerXAnchor),
imageInnerContainer.centerYAnchor.constraint(equalTo: imageOuterContainer.centerYAnchor),
])
NSLayoutConstraint.activate([
imageOuterContainer.heightAnchor.constraint(equalToConstant: 48),
])
return imageOuterContainer
}()
private lazy var header: UILabel = {
@ -93,6 +124,10 @@ class NameEducationSheet: StackSheetViewController {
"PROFILE_NAME_EDUCATION_SHEET_BULLET_3",
comment: "Third bullet point for the explainer sheet for profile names",
),
OWSLocalizedString(
"PROFILE_NAME_EDUCATION_SHEET_BULLET_4",
comment: "Fourth bullet point for the explainer sheet for profile names",
),
]
case .group:
[
@ -108,16 +143,20 @@ class NameEducationSheet: StackSheetViewController {
"GROUP_NAME_EDUCATION_SHEET_BULLET_3",
comment: "Third bullet point for the explainer sheet for group names",
),
OWSLocalizedString(
"GROUP_NAME_EDUCATION_SHEET_BULLET_4",
comment: "Fourth bullet point for the explainer sheet for group names",
),
]
}
}
private class ListPointView: UIStackView {
private class BulletPointView: UIStackView {
init(text: String) {
super.init(frame: .zero)
self.axis = .horizontal
self.alignment = .center
self.alignment = .firstBaseline
self.spacing = 8
let label = UILabel()
@ -126,17 +165,13 @@ class NameEducationSheet: StackSheetViewController {
label.textColor = .label
label.font = .dynamicTypeBody
let bulletPoint = UIView()
bulletPoint.backgroundColor = UIColor.Signal.tertiaryLabel
let bulletPoint = UILabel()
bulletPoint.text = ""
bulletPoint.font = .dynamicTypeBody
addArrangedSubview(.spacer(withWidth: 4))
addArrangedSubview(bulletPoint)
addArrangedSubview(label)
bulletPoint.autoSetDimension(.width, toSize: 4)
bulletPoint.autoPinHeightToSuperview(withMargin: 4)
bulletPoint.layer.cornerRadius = 2
label.setCompressionResistanceHigh()
}
required init(coder: NSCoder) {

View File

@ -593,6 +593,7 @@ struct ConversationHeaderBuilder {
),
presentationContext: .nonMessageBubble,
numberOfLines: 1,
signalSymbolRange: nil,
onTap: { [weak delegate] in
delegate?.didTapMemberLabel()
},

View File

@ -25,6 +25,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
)
@ -38,6 +39,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
CVCBottomViewType.messageRequestView(
@ -49,6 +51,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
)
@ -62,6 +65,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
CVCBottomViewType.messageRequestView(
@ -73,6 +77,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
)
@ -86,6 +91,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
CVCBottomViewType.messageRequestView(
@ -97,6 +103,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
)
@ -110,6 +117,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
CVCBottomViewType.messageRequestView(
@ -121,6 +129,7 @@ class ConversationViewControllerTest: SignalBaseTest {
isThreadFromHiddenRecipient: false,
hasReportedSpam: false,
isLocalUserInvitedMember: false,
showReviewRequestsCarefullyWarning: false,
),
),
)

View File

@ -4256,10 +4256,13 @@
"GROUP_NAME_EDUCATION_SHEET_BULLET_1" = "Be cautious of groups that impersonate organizations and businesses";
/* Second bullet point for the explainer sheet for group names */
"GROUP_NAME_EDUCATION_SHEET_BULLET_2" = "Profile names of members in groups are not verified";
"GROUP_NAME_EDUCATION_SHEET_BULLET_2" = "Signal can't verify names and photos";
/* Third bullet point for the explainer sheet for group names */
"GROUP_NAME_EDUCATION_SHEET_BULLET_3" = "Dont share personal information with people you dont know";
"GROUP_NAME_EDUCATION_SHEET_BULLET_3" = "Signal will never contact you for your registration code, PIN, or recovery key";
/* Fourth bullet point for the explainer sheet for group names */
"GROUP_NAME_EDUCATION_SHEET_BULLET_4" = "Dont share personal information with people you dont know";
/* Header for the explainer sheet for group names */
"GROUP_NAME_EDUCATION_SHEET_HEADER_FORMAT" = "<bold>Group names</bold> are chosen by members of the group.";
@ -5518,8 +5521,8 @@
/* A prompt notifying that the user must share their profile with this group. */
"MESSAGE_REQUEST_VIEW_EXISTING_GROUP_PROMPT" = "Continue your chat with this group and share your name and photo with its members?";
/* A prompt asking if the user wants to accept a conversation invite. Embeds {{contact name}}. */
"MESSAGE_REQUEST_VIEW_NEW_CONTACT_PROMPT_FORMAT" = "Let %@ message you and share your name and photo with them? They wont know youve seen their message until you accept.";
/* A prompt asking if the user wants to accept a conversation invite. */
"MESSAGE_REQUEST_VIEW_NEW_CONTACT_PROMPT" = "Let this person message you and share your name and photo with them? They wont know youve seen their message until you accept.";
/* A prompt asking if the user wants to accept a group invite. */
"MESSAGE_REQUEST_VIEW_NEW_GROUP_PROMPT" = "Join this group and share your name and photo with its members? They wont know youve seen their messages until you accept.";
@ -7148,13 +7151,16 @@
"PROFILE_NAME_CHANGE_SYSTEM_NONCONTACT_FORMAT" = "%@ changed their profile name to %@.";
/* First bullet point for the explainer sheet for profile names */
"PROFILE_NAME_EDUCATION_SHEET_BULLET_1" = "Profile names are not verified";
"PROFILE_NAME_EDUCATION_SHEET_BULLET_1" = "Signal can't verify names and photos";
/* Second bullet point for the explainer sheet for profile names */
"PROFILE_NAME_EDUCATION_SHEET_BULLET_2" = "Be cautious of accounts that impersonate others";
"PROFILE_NAME_EDUCATION_SHEET_BULLET_2" = "Signal will never contact you for your registration code, PIN, or recovery key";
/* Third bullet point for the explainer sheet for profile names */
"PROFILE_NAME_EDUCATION_SHEET_BULLET_3" = "Dont share personal information with people you dont know";
"PROFILE_NAME_EDUCATION_SHEET_BULLET_3" = "Be cautious of accounts that impersonate others";
/* Fourth bullet point for the explainer sheet for profile names */
"PROFILE_NAME_EDUCATION_SHEET_BULLET_4" = "Dont share personal information with people you dont know";
/* Header for the explainer sheet for profile names */
"PROFILE_NAME_EDUCATION_SHEET_HEADER_FORMAT" = "<bold>Profile names</bold> on Signal are chosen by their account holder.";
@ -9635,7 +9641,7 @@
"SYSTEM_MESSAGE_DEFAULT_DISAPPEARING_MESSAGE_TIMER_FORMAT" = "The disappearing message time will be set to %@ when you message them.";
/* Indicator warning about an unknown contact thread */
"SYSTEM_MESSAGE_UNKNOWN_THREAD_REVIEW_CAREFULLY_WARNING" = "Review Carefully";
"SYSTEM_MESSAGE_UNKNOWN_THREAD_REVIEW_CAREFULLY_WARNING" = "Review requests carefully";
/* Tap to Replace Emoji string for reaction configuration */
"TAP_REPLACE_EMOJI" = "Tap to replace an emoji";
@ -9667,12 +9673,6 @@
/* Label for a group you are not in which has four members, listing their names */
"THREAD_DETAILS_FOUR_MEMBERS" = "%1$@, %2$@, %3$@, and %4$@";
/* Label displayed below group info. This is the predicate part of the sentence 'Group names are not verified'. Embeds {{ THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT }} */
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE" = "%@ are not verified";
/* Label displayed below group info. This is the subject part of the sentence 'Group names are not verified'. It is embedded into THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_PREDICATE. */
"THREAD_DETAILS_GROUP_NAMES_ARE_NOT_VERIFIED_SUBJECT" = "Group names";
/* Label for a group with more than four members, listing the first three members' names and embedding THREAD_DETAILS_OTHER_MEMBERS_COUNT_%ld as a count of other members */
"THREAD_DETAILS_MANY_MEMBERS" = "%1$@, %2$@, %3$@, and %4$@";
@ -9691,11 +9691,8 @@
/* A string indicating a mutual group the user shares with this contact. Embeds {{mutual group name}} */
"THREAD_DETAILS_ONE_MUTUAL_GROUP" = "Member of %@";
/* Label displayed below profiles. This is the predicate part of the sentence 'Profile names are not verified'. Embeds {{ THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT }} */
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE" = "%@ are not verified";
/* Label displayed below profiles. This is the subject part of the sentence 'Profile names are not verified'. It is embedded into THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_PREDICATE. */
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT" = "Profile names";
/* Label displayed below profiles */
"THREAD_DETAILS_PROFILE_NAMES_ARE_NOT_VERIFIED_SUBJECT" = "Name not verified";
/* Indicator that a blurred avatar can be revealed by tapping. */
"THREAD_DETAILS_TAP_TO_UNBLUR_AVATAR" = "View";

View File

@ -61,7 +61,6 @@ public class OWSContactsManager: NSObject, ContactsManagerProtocol {
private let addressesAllowingAvatarDownloadCache = AtomicSet<SignalServiceAddress>(lock: .init())
private let groupIdsAllowingAvatarDownloadCache = AtomicSet<Data>(lock: .init())
private let addressesNotNeedingLowTrustWarningCache = AtomicSet<SignalServiceAddress>(lock: .init())
private let groupIdsNotNeedingLowTrustWarningCache = AtomicSet<Data>(lock: .init())
private let intersectionQueue = DispatchQueue(label: "org.signal.contacts.intersection")
@ -264,38 +263,6 @@ private class SystemContactsCache {
extension OWSContactsManager: ContactManager {
// MARK: Low Trust
public func isLowTrustContact(contactThread: TSContactThread, tx: DBReadTransaction) -> Bool {
let address = contactThread.contactAddress
if addressesNotNeedingLowTrustWarningCache.contains(address) {
return false
}
if SSKEnvironment.shared.profileManagerRef.isThread(inProfileWhitelist: contactThread, transaction: tx) {
addressesNotNeedingLowTrustWarningCache.insert(address)
return false
}
if !contactThread.hasPendingMessageRequest(transaction: tx) {
return false
}
if
isInWhitelistedGroupsWithLocalUser(
otherAddress: address,
requireMultipleMutualGroups: true,
tx: tx,
)
{
addressesNotNeedingLowTrustWarningCache.insert(address)
return false
}
return true
}
public func isLowTrustGroup(groupThread: TSGroupThread, tx: DBReadTransaction) -> Bool {
if groupIdsNotNeedingLowTrustWarningCache.contains(groupThread.groupId) {
return false

View File

@ -219,6 +219,44 @@ public extension NSAttributedString {
}
}
public extension NSMutableAttributedString {
func applyAttributesToRangeAvoidingSubrange(attributes: [NSAttributedString.Key: Any], range: NSRange, subrangeToAvoid: NSRange) {
guard NSIntersectionRange(range, subrangeToAvoid).length > 0 else {
// subrange does not exist in range, can apply to entire range.
for (key, value) in attributes {
addAttribute(key, value: value, range: range)
}
return
}
let rangeEnd = NSMaxRange(range)
let subrangeToAvoidStart = subrangeToAvoid.location
let subrangeToAvoidEnd = NSMaxRange(subrangeToAvoid)
if subrangeToAvoidStart > range.location {
let before = NSRange(
location: range.location,
length: subrangeToAvoidStart - range.location,
)
for (key, value) in attributes {
addAttribute(key, value: value, range: before)
}
}
if subrangeToAvoidEnd < rangeEnd {
let after = NSRange(
location: subrangeToAvoidEnd,
length: rangeEnd - subrangeToAvoidEnd,
)
for (key, value) in attributes {
addAttribute(key, value: value, range: after)
}
}
}
}
/// Unicode isolates help us avoid RTL/LTR formatting issues when substituting
/// strings into other strings.
///

View File

@ -0,0 +1,64 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Testing
@testable import SignalServiceKit
struct NSAttributedStringExtensionTests {
@Test
func testAttributeAvoidingSubrange() {
var mutableString = NSMutableAttributedString(string: "🐝 kind to all kinds")
let attributes = [NSAttributedString.Key.foregroundColor: UIColor.yellow]
var subrangeToAvoid = (mutableString.string as NSString).range(of: "all")
mutableString.applyAttributesToRangeAvoidingSubrange(
attributes: attributes,
range: mutableString.entireRange,
subrangeToAvoid: subrangeToAvoid,
)
mutableString.enumerateAttributes(in: mutableString.entireRange, options: []) { attrs, subrange, _ in
if NSIntersectionRange(subrange, subrangeToAvoid).length > 0 {
#expect(attrs.isEmpty)
} else {
#expect(attrs[NSAttributedString.Key.foregroundColor] as! NSObject == UIColor.yellow)
}
}
// subrange not intersecting range.
mutableString = NSMutableAttributedString(string: "🐝 kind to all kinds")
subrangeToAvoid = NSRange(location: 25, length: 1)
mutableString.applyAttributesToRangeAvoidingSubrange(
attributes: attributes,
range: mutableString.entireRange,
subrangeToAvoid: subrangeToAvoid,
)
mutableString.enumerateAttributes(in: mutableString.entireRange, options: []) { attrs, subrange, _ in
#expect(attrs[NSAttributedString.Key.foregroundColor] as! NSObject == UIColor.yellow, "Entire string should have attribute")
}
// subrange with partial intersection
mutableString = NSMutableAttributedString(string: "🐝 kind to all kinds")
subrangeToAvoid = NSRange(location: 18, length: 10)
mutableString.applyAttributesToRangeAvoidingSubrange(
attributes: attributes,
range: mutableString.entireRange,
subrangeToAvoid: subrangeToAvoid,
)
mutableString.enumerateAttributes(in: mutableString.entireRange, options: []) { attrs, subrange, _ in
if NSIntersectionRange(subrange, subrangeToAvoid).length > 0 {
#expect(attrs.isEmpty)
} else {
#expect(attrs[NSAttributedString.Key.foregroundColor] as! NSObject == UIColor.yellow)
}
}
}
}

View File

@ -38,6 +38,7 @@ public enum SignalSymbol: Character {
case creditcard = "\u{E127}"
case edit = "\u{E030}"
case error = "\u{E032}"
case errorTriangle = "\u{E092}"
case file = "\u{E034}"
case forward = "\u{E035}"
case gif = "\u{E037}"
@ -84,6 +85,7 @@ public enum SignalSymbol: Character {
case personMinus = "\u{E062}"
case personPlus = "\u{E061}"
case personX = "\u{E060}"
case personQuestion = "\u{E06A}"
case phone = "\u{E063}"
case phoneFill = "\u{E064}"
case photo = "\u{E065}"

View File

@ -171,6 +171,13 @@ extension UIColor.Signal {
)
}
public static var warningLabel: UIColor {
UIColor(
light: UIColor(rgbHex: 0xB44828),
dark: UIColor(rgbHex: 0xEB977D),
)
}
// MARK: Background
public static var background: UIColor {
@ -530,6 +537,10 @@ extension Color.Signal {
Color(UIColor.Signal.emphasisLabel)
}
public static var warningLabel: Color {
Color(UIColor.Signal.warningLabel)
}
// MARK: Background
public static var background: Color {

View File

@ -18,6 +18,7 @@ public class CVCapsuleLabel: UILabel {
case messageBubbleRegular
case messageBubbleQuoteReplyIncoming
case messageBubbleQuoteReplyOutgoing
case nameNotVerifiedWarning
}
public let highlightRange: NSRange
@ -43,6 +44,7 @@ public class CVCapsuleLabel: UILabel {
presentationContext: PresentationContext,
lineBreakMode: NSLineBreakMode = .byTruncatingTail,
numberOfLines: Int = 0,
signalSymbolRange: NSRange?,
onTap: (() -> Void)?,
) {
self.highlightRange = highlightRange
@ -62,11 +64,9 @@ public class CVCapsuleLabel: UILabel {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapMemberLabel)))
let attributedString = NSMutableAttributedString(attributedString: attributedText)
attributedString.addAttribute(.font, value: self.font!, range: attributedText.entireRange)
applyFontToAttributedString(attributedString, signalSymbolRange: signalSymbolRange)
attributedString.addAttribute(.foregroundColor, value: textColor, range: attributedText.entireRange)
// The highlighted text may have different font than the sender name
attributedString.addAttribute(.font, value: highlightFont, range: highlightRange)
self.attributedText = attributedString
}
@ -88,9 +88,35 @@ public class CVCapsuleLabel: UILabel {
return textColor.withAlphaComponent(0.32)
}
return textColor.withAlphaComponent(0.14)
case .nameNotVerifiedWarning:
if Theme.isDarkThemeEnabled {
return textColor.withAlphaComponent(0.2)
}
return textColor.withAlphaComponent(0.12)
}
}
private func applyFontToAttributedString(_ attributedString: NSMutableAttributedString, signalSymbolRange: NSRange?) {
guard let signalSymbolRange else {
attributedString.addAttribute(.font, value: self.font!, range: attributedString.entireRange)
// The highlighted text may have different font than the sender name
attributedString.addAttribute(.font, value: highlightFont, range: highlightRange)
return
}
// Apply font avoiding the signal symbol
attributedString.applyAttributesToRangeAvoidingSubrange(
attributes: [.font: self.font!],
range: attributedString.entireRange,
subrangeToAvoid: signalSymbolRange,
)
attributedString.applyAttributesToRangeAvoidingSubrange(
attributes: [.font: highlightFont],
range: highlightRange,
subrangeToAvoid: signalSymbolRange,
)
}
@objc
func didTapMemberLabel() {
onTap?()
@ -288,6 +314,7 @@ public class CVCapsuleLabel: UILabel {
highlightFont: UIFont,
presentationContext: CVCapsuleLabel.PresentationContext,
maxWidth: CGFloat,
signalSymbolRange: NSRange?,
) -> CGSize {
let label = CVCapsuleLabel(
attributedText: attributedText,
@ -297,6 +324,7 @@ public class CVCapsuleLabel: UILabel {
highlightFont: highlightFont,
axLabelPrefix: nil,
presentationContext: presentationContext,
signalSymbolRange: signalSymbolRange,
onTap: nil,
)
return label.labelSize(maxWidth: maxWidth)

View File

@ -246,6 +246,7 @@ public class ContactCellView: ManualStackView {
),
presentationContext: .nonMessageBubble,
numberOfLines: 1,
signalSymbolRange: nil,
onTap: nil,
)