Member labels in quote replies

This commit is contained in:
kate-signal 2026-02-05 17:15:51 -05:00 committed by GitHub
parent 16303ee655
commit d63dbbb417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 164 additions and 68 deletions

View File

@ -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

View File

@ -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")

View File

@ -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
}

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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