From d63dbbb417e8053c9abb6debc40d31208307c063 Mon Sep 17 00:00:00 2001 From: kate-signal Date: Thu, 5 Feb 2026 17:15:51 -0500 Subject: [PATCH] Member labels in quote replies --- .../CellViews/QuotedMessageView.swift | 52 ++++++++++--- .../Components/CVComponentSenderName.swift | 42 +++++----- .../Components/CVComponentState.swift | 13 +++- .../ConversationHeaderBuilder.swift | 20 ++--- .../ConversationView/CVCapsuleLabel.swift | 77 ++++++++++++++----- .../RecipientPickers/ContactCellView.swift | 20 ++--- SignalUI/Sending/QuotedReplyModel.swift | 8 ++ 7 files changed, 164 insertions(+), 68 deletions(-) diff --git a/Signal/ConversationView/CellViews/QuotedMessageView.swift b/Signal/ConversationView/CellViews/QuotedMessageView.swift index cfc9667add..4be9b13ed7 100644 --- a/Signal/ConversationView/CellViews/QuotedMessageView.swift +++ b/Signal/ConversationView/CellViews/QuotedMessageView.swift @@ -24,6 +24,7 @@ public class QuotedMessageView: ManualStackViewWithLayer { let isOutgoing: Bool let isForPreview: Bool let quotedAuthorName: String + let memberLabel: String? var quotedInteractionIdentifier: InteractionSnapshotIdentifier? { guard let timestamp = quotedReplyModel.originalMessageTimestamp else { @@ -46,7 +47,7 @@ public class QuotedMessageView: ManualStackViewWithLayer { private let remotelySourcedContentStack = ManualStackViewWithLayer(name: "remotelySourcedContentStack") private let stripeView = UIView() - private let quotedAuthorLabel = CVLabel() + private var quotedAuthorLabel = UILabel() private let quotedTextLabel = CVLabel() private let quoteContentSourceLabel = CVLabel() private let quoteReactionHeaderLabel = CVLabel() @@ -76,6 +77,7 @@ public class QuotedMessageView: ManualStackViewWithLayer { for: quotedReplyModel.originalMessageAuthorAddress, tx: transaction, ).resolvedValue(), + memberLabel: quotedReplyModel.originalMessageMemberLabel, ) } @@ -102,6 +104,7 @@ public class QuotedMessageView: ManualStackViewWithLayer { for: quotedReplyModel.originalMessageAuthorAddress, tx: transaction, ).resolvedValue(), + memberLabel: quotedReplyModel.originalMessageMemberLabel, ) } @@ -118,7 +121,14 @@ public class QuotedMessageView: ManualStackViewWithLayer { var isOutgoing: Bool { state.isOutgoing } var isIncoming: Bool { !isOutgoing } var isForPreview: Bool { state.isForPreview } - var quotedAuthorName: String { state.quotedAuthorName } + fileprivate var quotedAuthorName: NSAttributedString { + let padding = " " + String(repeating: SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue, count: 2) + if let labelString = state.memberLabel { + return NSAttributedString(string: state.quotedAuthorName + padding + labelString) + } else { + return NSAttributedString(string: state.quotedAuthorName) + } + } let stripeThickness: CGFloat = 4 var quotedAuthorFont: UIFont { UIFont.dynamicTypeSubheadlineClamped.semibold() } @@ -264,7 +274,7 @@ public class QuotedMessageView: ManualStackViewWithLayer { if quotedReplyModel.originalMessageAuthorAddress.isLocalAddress { authorName = CommonStrings.you } else { - authorName = quotedAuthorName + authorName = quotedAuthorName.string } let text: String @@ -556,8 +566,23 @@ public class QuotedMessageView: ManualStackViewWithLayer { var innerVStackSubviews = [UIView]() - let quotedAuthorLabelConfig = configurator.quotedAuthorLabelConfig - quotedAuthorLabelConfig.applyForRendering(label: quotedAuthorLabel) + if let memberLabel = state.memberLabel { + quotedAuthorLabel = CVCapsuleLabel( + attributedText: configurator.quotedAuthorName, + textColor: configurator.quotedTextColor, + font: configurator.quotedAuthorFont, + highlightRange: (configurator.quotedAuthorName.string as NSString).range(of: memberLabel, options: .backwards), + highlightFont: .dynamicTypeFootnote, + axLabelPrefix: nil, + isQuotedReply: true, + lineBreakMode: .byTruncatingTail, + numberOfLines: 1, + ) + } else { + let quotedAuthorLabelConfig = configurator.quotedAuthorLabelConfig + quotedAuthorLabelConfig.applyForRendering(label: quotedAuthorLabel) + } + innerVStackSubviews.append(quotedAuthorLabel) let quotedTextLabelConfig = configurator.quotedTextLabelConfig @@ -854,10 +879,19 @@ public class QuotedMessageView: ManualStackViewWithLayer { var innerVStackSubviewInfos = [ManualStackSubviewInfo]() let quotedAuthorLabelConfig = configurator.quotedAuthorLabelConfig - let quotedAuthorSize = CVText.measureLabel( - config: quotedAuthorLabelConfig, - maxWidth: maxLabelWidth, - ) + let quotedAuthorSize: CGSize + if state.memberLabel != nil { + quotedAuthorSize = CVCapsuleLabel.measureLabel( + config: quotedAuthorLabelConfig, + maxWidth: maxLabelWidth, + ) + } else { + quotedAuthorSize = CVText.measureLabel( + config: quotedAuthorLabelConfig, + maxWidth: maxLabelWidth, + ) + } + innerVStackSubviewInfos.append(quotedAuthorSize.asManualSubviewInfo) let quotedTextLabelConfig = configurator.quotedTextLabelConfig diff --git a/Signal/ConversationView/Components/CVComponentSenderName.swift b/Signal/ConversationView/Components/CVComponentSenderName.swift index bfc4b59afe..b3a8fb376a 100644 --- a/Signal/ConversationView/Components/CVComponentSenderName.swift +++ b/Signal/ConversationView/Components/CVComponentSenderName.swift @@ -59,7 +59,6 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { let outerStack = componentView.outerStack let innerStack = componentView.innerStack - let label = componentView.label outerStack.reset() innerStack.reset() @@ -71,7 +70,6 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { innerStack.addSubviewToFillSuperviewEdges(backgroundView) } - labelConfig.applyForRendering(label: label) if let memberLabel = state.memberLabel { // Finds the first or last occurance of the member label. // Since member label is appended to the end in LTR and beginning in RTL, this @@ -80,13 +78,21 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { if !CurrentAppContext().isRTL { options.insert(.backwards) } - - label.highlightRange = (senderName.string as NSString).range(of: memberLabel, options: options) - label.highlightFont = .dynamicTypeFootnote + componentView.label = CVCapsuleLabel( + attributedText: senderName, + textColor: senderNameColor, + font: UIFont.dynamicTypeFootnote.semibold(), + highlightRange: (senderName.string as NSString).range(of: memberLabel, options: options), + highlightFont: .dynamicTypeFootnote, + axLabelPrefix: nil, // handled separately in CVItemViewState + isQuotedReply: false, + ) + } else { + labelConfig.applyForRendering(label: componentView.label) } var subviews: [UIView] = [] - subviews.append(label) + subviews.append(componentView.label) innerStack.configure( config: innerStackConfig, @@ -118,18 +124,6 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { ) } - private var memberLabelConfig: CVLabelConfig { - let font = UIFont.dynamicTypeFootnote - return CVLabelConfig( - text: .text(memberLabel ?? ""), - displayConfig: .forUnstyledText(font: font, textColor: senderNameColor), - font: font, - textColor: senderNameColor, - numberOfLines: 0, - lineBreakMode: .byWordWrapping, - ) - } - private var outerStackConfig: CVStackViewConfig { CVStackViewConfig( axis: .vertical, @@ -165,7 +159,15 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { ) var subviewInfos: [ManualStackSubviewInfo] = [] - let labelSize = CVCapsuleLabel.measureLabel(config: labelConfig, maxWidth: maxWidth) + let labelSize: CGSize + if state.memberLabel != nil { + labelSize = CVCapsuleLabel.measureLabel( + config: labelConfig, + maxWidth: maxWidth, + ) + } else { + labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxWidth) + } let labelInfo = labelSize.asManualSubviewInfo subviewInfos.insert(labelInfo, at: 0) @@ -192,7 +194,7 @@ public class CVComponentSenderName: CVComponentBase, CVComponent { // It could be the entire item or some part thereof. public class CVComponentViewSenderName: NSObject, CVComponentView { - fileprivate let label = CVCapsuleLabel() + fileprivate var label = UILabel() fileprivate let outerStack = ManualStackView(name: "CVComponentViewSenderName.outerStack") fileprivate let innerStack = ManualStackView(name: "CVComponentViewSenderName.innerStack") diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index 9981b20246..684c2ed077 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -1556,7 +1556,18 @@ private extension CVComponentState.Builder { transaction: transaction, ) } else if let quotedMessage = message.quotedMessage { - return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, transaction: transaction) + var memberLabel: String? + if + BuildFlags.MemberLabel.display, let groupThread = thread as? TSGroupThread, + let originalMessageAuthor = quotedMessage.authorAddress.aci + { + memberLabel = groupThread.groupModel.groupMembership.memberLabel(for: originalMessageAuthor)?.labelForRendering() + memberLabel = memberLabel? + .components(separatedBy: .whitespaces) + .joined(separator: SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue) + } + + return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, memberLabel: memberLabel, transaction: transaction) } else { return nil } diff --git a/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift b/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift index c4963c83df..b654b4fc86 100644 --- a/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift +++ b/Signal/src/ViewControllers/ThreadSettings/ConversationHeaderBuilder.swift @@ -526,15 +526,17 @@ struct ConversationHeaderBuilder { mutating func addMemberLabel(label: String, color: UIColor) -> UILabel { subviews.append(UIView.spacer(withHeight: 4)) - let memberLabelLabel = CVCapsuleLabel() - memberLabelLabel.text = label - memberLabelLabel.textColor = color - memberLabelLabel.numberOfLines = 0 - memberLabelLabel.highlightRange = NSRange(location: 0, length: (label as NSString).length) - memberLabelLabel.highlightFont = .dynamicTypeSubheadlineClamped - memberLabelLabel.axLabelPrefix = OWSLocalizedString( - "MEMBER_LABEL_AX_PREFIX", - comment: "Accessibility prefix for member labels.", + let memberLabelLabel = CVCapsuleLabel( + attributedText: NSAttributedString(string: label), + textColor: color, + font: nil, + highlightRange: NSRange(location: 0, length: (label as NSString).length), + highlightFont: .dynamicTypeSubheadlineClamped, + axLabelPrefix: OWSLocalizedString( + "MEMBER_LABEL_AX_PREFIX", + comment: "Accessibility prefix for member labels.", + ), + isQuotedReply: false, ) subviews.append(memberLabelLabel) diff --git a/SignalUI/ConversationView/CVCapsuleLabel.swift b/SignalUI/ConversationView/CVCapsuleLabel.swift index b07f81961e..390b717438 100644 --- a/SignalUI/ConversationView/CVCapsuleLabel.swift +++ b/SignalUI/ConversationView/CVCapsuleLabel.swift @@ -11,13 +11,56 @@ import SignalServiceKit * highlightFont allows for the capsule text to be a different font (e.g. bold or not bold) from the rest of the attributed text. */ public class CVCapsuleLabel: UILabel { - public var highlightRange: NSRange? - public var highlightFont: UIFont? - public var axLabelPrefix: String? + public let highlightRange: NSRange + public let highlightFont: UIFont + public let axLabelPrefix: String? + public let isQuotedReply: Bool private static let horizontalInset: CGFloat = 6 private static let verticalInset: CGFloat = 1 + public init( + attributedText: NSAttributedString, + textColor: UIColor, + font: UIFont?, + highlightRange: NSRange, + highlightFont: UIFont, + axLabelPrefix: String?, + isQuotedReply: Bool, + lineBreakMode: NSLineBreakMode = .byWordWrapping, + numberOfLines: Int = 0, + ) { + self.highlightRange = highlightRange + self.highlightFont = highlightFont + self.axLabelPrefix = axLabelPrefix + self.isQuotedReply = isQuotedReply + + super.init(frame: .zero) + + self.font = font + self.attributedText = attributedText + self.textColor = textColor + self.lineBreakMode = lineBreakMode + self.numberOfLines = numberOfLines + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var capsuleColor: UIColor { + if Theme.isDarkThemeEnabled { + if isQuotedReply { + return UIColor.white.withAlphaComponent(0.20) + } + return textColor.withAlphaComponent(0.25) + } + if isQuotedReply { + return UIColor.white.withAlphaComponent(0.36) + } + return textColor.withAlphaComponent(0.1) + } + override public func drawText(in rect: CGRect) { guard let text = self.text else { super.drawText(in: rect) @@ -29,9 +72,7 @@ public class CVCapsuleLabel: UILabel { attributedString.addAttribute(.foregroundColor, value: self.textColor!, range: text.entireRange) // The highlighted text may have different font than the sender name - if let highlightFont, let highlightRange { - attributedString.addAttribute(.font, value: highlightFont, range: highlightRange) - } + attributedString.addAttribute(.font, value: highlightFont, range: highlightRange) let textStorage = NSTextStorage(attributedString: attributedString) let layoutManager = NSLayoutManager() @@ -51,19 +92,16 @@ public class CVCapsuleLabel: UILabel { horizontalInset = Self.horizontalInset } - var needsLeadingPadding = false - if let highlightRange { - needsLeadingPadding = highlightRange.location == 0 && highlightRange.length == (text as NSString).length - let glyphRange = layoutManager.glyphRange(forCharacterRange: highlightRange, actualCharacterRange: nil) + let needsLeadingPadding = highlightRange.location == 0 && highlightRange.length == (text as NSString).length + let glyphRange = layoutManager.glyphRange(forCharacterRange: highlightRange, actualCharacterRange: nil) - let highlightColor = Theme.isDarkThemeEnabled ? textColor.withAlphaComponent(0.25) : textColor.withAlphaComponent(0.1) - layoutManager.enumerateEnclosingRects(forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { rect, _ in - let hOffset = needsLeadingPadding ? horizontalInset : 0 - let roundedRect = rect.offsetBy(dx: hOffset, dy: -1).insetBy(dx: -Self.horizontalInset, dy: -Self.verticalInset) - let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: roundedRect.height / 2) - highlightColor.setFill() - path.fill() - } + let highlightColor = capsuleColor + layoutManager.enumerateEnclosingRects(forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { rect, _ in + let hOffset = needsLeadingPadding ? horizontalInset : 0 + let roundedRect = rect.offsetBy(dx: hOffset, dy: -1).insetBy(dx: -Self.horizontalInset, dy: -Self.verticalInset) + let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: roundedRect.height / 2) + highlightColor.setFill() + path.fill() } let textOrigin = needsLeadingPadding ? CGPoint(x: .zero + horizontalInset, y: .zero) : CGPoint.zero @@ -87,8 +125,7 @@ public class CVCapsuleLabel: UILabel { func highlightLabelSize() -> CGSize { guard let text = self.text else { return .zero } - guard let fontToUse = highlightFont else { return .zero } - let attributes: [NSAttributedString.Key: Any] = [.font: fontToUse] + let attributes: [NSAttributedString.Key: Any] = [.font: highlightFont] let size = (text as NSString).size(withAttributes: attributes) return CGSize( width: size.width + Self.horizontalInset * 2, diff --git a/SignalUI/RecipientPickers/ContactCellView.swift b/SignalUI/RecipientPickers/ContactCellView.swift index 5f9e84c2b8..b250dd3f5a 100644 --- a/SignalUI/RecipientPickers/ContactCellView.swift +++ b/SignalUI/RecipientPickers/ContactCellView.swift @@ -234,15 +234,17 @@ public class ContactCellView: ManualStackView { if let memberLabel = configuration.memberLabel { - let memberLabelLabel = CVCapsuleLabel() - memberLabelLabel.text = memberLabel.label - memberLabelLabel.textColor = memberLabel.groupNameColor - memberLabelLabel.numberOfLines = 0 - memberLabelLabel.highlightRange = NSRange(location: 0, length: (memberLabel.label as NSString).length) - memberLabelLabel.highlightFont = .dynamicTypeCaption1Clamped - memberLabelLabel.axLabelPrefix = OWSLocalizedString( - "MEMBER_LABEL_AX_PREFIX", - comment: "Accessibility prefix for member labels.", + let memberLabelLabel = CVCapsuleLabel( + attributedText: NSAttributedString(string: memberLabel.label), + textColor: memberLabel.groupNameColor, + font: nil, + highlightRange: NSRange(location: 0, length: (memberLabel.label as NSString).length), + highlightFont: .dynamicTypeCaption1Clamped, + axLabelPrefix: OWSLocalizedString( + "MEMBER_LABEL_AX_PREFIX", + comment: "Accessibility prefix for member labels.", + ), + isQuotedReply: false, ) textStackSubviews.append(memberLabelLabel) diff --git a/SignalUI/Sending/QuotedReplyModel.swift b/SignalUI/Sending/QuotedReplyModel.swift index a12b42bc4b..4bf06e3b57 100644 --- a/SignalUI/Sending/QuotedReplyModel.swift +++ b/SignalUI/Sending/QuotedReplyModel.swift @@ -17,6 +17,8 @@ public class QuotedReplyModel { /// Address of the original message's author, be it StoryMessage or TSMessage. public let originalMessageAuthorAddress: SignalServiceAddress + public let originalMessageMemberLabel: String? + public let isOriginalMessageAuthorLocalUser: Bool /// IFF the original's content was a story message, the emoji used @@ -248,6 +250,7 @@ public class QuotedReplyModel { return QuotedReplyModel( originalMessageTimestamp: storyMessage.timestamp, originalMessageAuthorAddress: storyMessage.authorAddress, + originalMessageMemberLabel: nil, isOriginalMessageAuthorLocalUser: isOriginalAuthorLocalUser, storyReactionEmoji: reactionEmoji, originalContent: originalContent, @@ -319,6 +322,7 @@ public class QuotedReplyModel { return QuotedReplyModel( originalMessageTimestamp: storyTimestamp, originalMessageAuthorAddress: SignalServiceAddress(storyAuthorAci), + originalMessageMemberLabel: nil, isOriginalMessageAuthorLocalUser: isOriginalMessageAuthorLocalUser, storyReactionEmoji: message.storyReactionEmoji, originalContent: .expiredStory, @@ -336,6 +340,7 @@ public class QuotedReplyModel { public static func build( replyMessage message: TSMessage, quotedMessage: TSQuotedMessage, + memberLabel: String?, transaction: DBReadTransaction, ) -> QuotedReplyModel { func buildQuotedReplyModel( @@ -350,6 +355,7 @@ public class QuotedReplyModel { return QuotedReplyModel( originalMessageTimestamp: quotedMessage.timestampValue?.uint64Value, originalMessageAuthorAddress: quotedMessage.authorAddress, + originalMessageMemberLabel: isOriginalAuthorLocalUser ? nil : memberLabel, isOriginalMessageAuthorLocalUser: isOriginalAuthorLocalUser, storyReactionEmoji: nil, originalContent: originalContent, @@ -458,6 +464,7 @@ public class QuotedReplyModel { private init( originalMessageTimestamp: UInt64?, originalMessageAuthorAddress: SignalServiceAddress, + originalMessageMemberLabel: String?, isOriginalMessageAuthorLocalUser: Bool, storyReactionEmoji: String?, originalContent: OriginalContent, @@ -465,6 +472,7 @@ public class QuotedReplyModel { ) { self.originalMessageTimestamp = originalMessageTimestamp self.originalMessageAuthorAddress = originalMessageAuthorAddress + self.originalMessageMemberLabel = originalMessageMemberLabel self.isOriginalMessageAuthorLocalUser = isOriginalMessageAuthorLocalUser self.storyReactionEmoji = storyReactionEmoji self.originalContent = originalContent