From 82fab30fcd514be631e1196ba3a06bba3ba34da5 Mon Sep 17 00:00:00 2001 From: kate-signal Date: Mon, 23 Feb 2026 15:21:45 -0500 Subject: [PATCH] Make admin name tappable --- .../Components/CVComponentBodyText.swift | 110 ++++++++++++++---- .../Components/CVComponentState.swift | 23 +++- ...ersationViewController+BodyTextItems.swift | 14 +++ .../LongTextViewController.swift | 2 + SignalUI/Appearance/SignalSymbols.swift | 1 + SignalUI/ConversationView/CVTextLabel.swift | 25 +++- 6 files changed, 147 insertions(+), 28 deletions(-) diff --git a/Signal/ConversationView/Components/CVComponentBodyText.swift b/Signal/ConversationView/Components/CVComponentBodyText.swift index a219c9e661..945f3e6647 100644 --- a/Signal/ConversationView/Components/CVComponentBodyText.swift +++ b/Signal/ConversationView/Components/CVComponentBodyText.swift @@ -385,7 +385,7 @@ public class CVComponentBodyText: CVComponentBase, CVComponent { lineBreakMode: .byWordWrapping, numberOfLines: 0, cacheKey: textViewConfig.cacheKey, - items: bodyTextState.items, + items: textViewConfig.linkItems, linkifyStyle: textViewConfig.linkifyStyle, ) } @@ -462,7 +462,7 @@ public class CVComponentBodyText: CVComponentBase, CVComponent { case .oversizeTextDownloading: return bodyTextLabelConfig(labelConfig: labelConfigForOversizeTextDownloading) case .remotelyDeleted: - return bodyTextLabelConfig(labelConfig: labelConfigForRemotelyDeleted) + return bodyTextLabelConfig(textViewConfig: textViewConfigForRemotelyDeleted) } } @@ -475,35 +475,101 @@ public class CVComponentBodyText: CVComponentBase, CVComponent { ) } - private var labelConfigForRemotelyDeleted: CVLabelConfig { - var text: String + private func buildAdminDeleteAttributedString(deleteAuthor: CVComponentState.DeleteAuthor) -> NSAttributedString { + let format = OWSLocalizedString( + "DELETED_BY_ADMIN", + comment: "Text indicating the message was remotely deleted by an admin. Embeds {{admin display name}}", + ) + + let attributedString = NSAttributedString.make( + fromFormat: format, + attributedFormatArgs: [ + .string( + deleteAuthor.displayName, + attributes: [.font: textMessageFont.bold(), .foregroundColor: deleteAuthor.groupColor], + ), + ], + ) + + return attributedString + } + + private func rangeOfFirstSubstring( + in attributedString: NSAttributedString, + withColor color: UIColor, + ) -> NSRange? { + var matchingRange: NSRange? + attributedString.enumerateAttribute( + .foregroundColor, + in: NSRange(location: 0, length: attributedString.length), + options: [], + ) { value, range, stop in + if + let currentColor = value as? UIColor, + currentColor == color + { + matchingRange = range + stop.pointee = true + } + } + return matchingRange + } + + private var textViewConfigForRemotelyDeleted: CVTextViewConfig { + let text = NSMutableAttributedString() + text.append( + SignalSymbol.xCircle.attributedString( + dynamicTypeBaseSize: textMessageFont.pointSize, + ) + " ", + ) + + var linkItems: [CVTextLabel.Item] = [] switch bodyText { case .remotelyDeleted(let deleteAuthor): if let deleteAuthor { - // TODO: make attributed string with icon and tappable display name. - let format = OWSLocalizedString( - "DELETED_BY_ADMIN", - comment: "Text indicating the message was remotely deleted by an admin. Embeds {{admin display name}}", - ) - text = String(format: format, deleteAuthor) + let attributedString = buildAdminDeleteAttributedString(deleteAuthor: deleteAuthor) + text.append(attributedString) + + if + let tapItemRange = rangeOfFirstSubstring( + in: text, + withColor: deleteAuthor.groupColor, + ) + { + linkItems.append(.deleteAuthor(deleteAuthorItem: CVTextLabel.DeleteAuthorItem( + deleteAuthorAci: deleteAuthor.aci, + range: tapItemRange, + ))) + } else { + owsFailDebug("Admin delete is missing tappable range") + } } else { fallthrough } default: - text = ( + let remoteDeleteNoAuthorMessage = ( isIncoming - ? OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted") - : OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you"), + ? NSAttributedString(string: OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted")) + : NSAttributedString(string: OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you")), ) + text.append(remoteDeleteNoAuthorMessage) } - return CVLabelConfig( - text: .text(text), - displayConfig: .forUnstyledText(font: textMessageFont.italic(), textColor: bodyTextColor), - font: textMessageFont.italic(), - textColor: bodyTextColor, - numberOfLines: 0, - lineBreakMode: .byWordWrapping, - textAlignment: .center, + + let displayConfiguration = HydratedMessageBody.DisplayConfiguration.messageBubble( + isIncoming: isIncoming, + revealedSpoilerIds: revealedSpoilerIds, + searchRanges: .matchedRanges([]), + ) + + return CVTextViewConfig( + text: .attributedText(text), + font: textMessageFont, + textColor: UIColor.Signal.secondaryLabel, + textAlignment: .natural, + displayConfiguration: displayConfiguration, + linkifyStyle: linkifyStyle, + linkItems: linkItems, + matchedSearchRanges: [], ) } @@ -792,7 +858,7 @@ extension CVComponentBodyText: CVAccessibilityComponent { case .oversizeTextDownloading: return labelConfigForOversizeTextDownloading.text.accessibilityDescription case .remotelyDeleted: - return labelConfigForRemotelyDeleted.text.accessibilityDescription + return textViewConfigForRemotelyDeleted.text.accessibilityDescription } } } diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index 83573bfcc1..1ab50923bd 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -5,6 +5,7 @@ import MobileCoin public import SignalServiceKit +import LibSignalClient import SignalUI public enum CVAttachment: Equatable { @@ -121,6 +122,12 @@ public struct CVComponentState: Equatable { let avatarDataSource: ConversationAvatarDataSource } + struct DeleteAuthor: Equatable { + let displayName: String + let aci: Aci + let groupColor: UIColor + } + let senderAvatar: SenderAvatar? enum BodyText: Equatable { @@ -133,7 +140,7 @@ public struct CVComponentState: Equatable { // We use the "body text" component to // render the "remotely deleted" indicator. - case remotelyDeleted(deleteAuthorName: String?) + case remotelyDeleted(deleteAuthor: DeleteAuthor?) var displayableText: DisplayableText? { switch self { @@ -1188,7 +1195,7 @@ private extension CVComponentState.Builder { } /// If the message was deleted remotely by an admin, display the admin's name. - private func displayNameForDeleteMessage(message: TSMessage) -> String? { + private func displayNameAndColorForDeleteMessage(message: TSMessage) -> CVComponentState.DeleteAuthor? { let adminDeleteManager = DependenciesBridge.shared.adminDeleteManager guard @@ -1208,10 +1215,16 @@ private extension CVComponentState.Builder { return nil } else { // Only display admin name if non self-delete. - return SSKEnvironment.shared.contactManagerRef.displayName( + let displayName = SSKEnvironment.shared.contactManagerRef.displayName( for: SignalServiceAddress(authorAci), tx: transaction, ).resolvedValue() + let groupNameColor = GroupNameColors.forThread(thread).color(for: authorAci) + return CVComponentState.DeleteAuthor( + displayName: displayName, + aci: authorAci, + groupColor: groupNameColor, + ) } } @@ -1223,8 +1236,8 @@ private extension CVComponentState.Builder { if message.wasRemotelyDeleted { // If the message has been remotely deleted, suppress everything else. - let deleteAuthor = displayNameForDeleteMessage(message: message) - self.bodyText = .remotelyDeleted(deleteAuthorName: deleteAuthor) + let remoteDeleteAuthor = displayNameAndColorForDeleteMessage(message: message) + self.bodyText = .remotelyDeleted(deleteAuthor: remoteDeleteAuthor) return build() } diff --git a/Signal/ConversationView/ConversationViewController+BodyTextItems.swift b/Signal/ConversationView/ConversationViewController+BodyTextItems.swift index 282773453f..4e56a6f1d9 100644 --- a/Signal/ConversationView/ConversationViewController+BodyTextItems.swift +++ b/Signal/ConversationView/ConversationViewController+BodyTextItems.swift @@ -67,6 +67,8 @@ extension ConversationViewController { didTapOrLongPressUnrevealedSpoiler(unrevealedSpoilerItem) case .referencedUser(let referencedUserItem): owsFailDebug("Should never have a referenced user item in body text, but tapped \(referencedUserItem)") + case .deleteAuthor(let deleteAuthor): + didTapOrLongPressDeleteAuthor(aci: deleteAuthor.deleteAuthorAci) } } @@ -104,6 +106,8 @@ extension ConversationViewController { didTapOrLongPressUnrevealedSpoiler(unrevealedSpoilerItem) case .referencedUser(let referencedUserItem): owsFailDebug("Should never have a referenced user item in body text, but long pressed \(referencedUserItem)") + case .deleteAuthor(let deleteAuthor): + didTapOrLongPressDeleteAuthor(aci: deleteAuthor.deleteAuthorAci) } } @@ -457,4 +461,14 @@ extension ConversationViewController { deletedInteractionIds: [], ) } + + // Taps and long presses do the same thing. + private func didTapOrLongPressDeleteAuthor(aci: Aci) { + ProfileSheetSheetCoordinator( + address: SignalServiceAddress(aci), + groupViewHelper: nil, + spoilerState: SpoilerRenderState(), + ) + .presentAppropriateSheet(from: self) + } } diff --git a/Signal/src/ViewControllers/LongTextViewController.swift b/Signal/src/ViewControllers/LongTextViewController.swift index 64b9e994fa..6ebff14dba 100644 --- a/Signal/src/ViewControllers/LongTextViewController.swift +++ b/Signal/src/ViewControllers/LongTextViewController.swift @@ -365,6 +365,8 @@ class LongTextViewController: OWSViewController { ) self.loadContent() return + case .deleteAuthor: + owsFailDebug("delete author should not appear in long message body") } } } diff --git a/SignalUI/Appearance/SignalSymbols.swift b/SignalUI/Appearance/SignalSymbols.swift index d5948bc2f2..61b54adc4a 100644 --- a/SignalUI/Appearance/SignalSymbols.swift +++ b/SignalUI/Appearance/SignalSymbols.swift @@ -103,6 +103,7 @@ public enum SignalSymbol: Character { case videoFill = "\u{E077}" case viewOnce = "\u{E078}" case viewOnceSlash = "\u{E079}" + case xCircle = "\u{E1EE}" // MARK: Localized symbols diff --git a/SignalUI/ConversationView/CVTextLabel.swift b/SignalUI/ConversationView/CVTextLabel.swift index d49bf19f4e..18e930e4cf 100644 --- a/SignalUI/ConversationView/CVTextLabel.swift +++ b/SignalUI/ConversationView/CVTextLabel.swift @@ -55,11 +55,24 @@ public class CVTextLabel: NSObject { // MARK: - + public struct DeleteAuthorItem: Equatable { + public let deleteAuthorAci: Aci + public let range: NSRange + + public init(deleteAuthorAci: Aci, range: NSRange) { + self.deleteAuthorAci = deleteAuthorAci + self.range = range + } + } + + // MARK: - + public enum Item: Equatable, CustomStringConvertible { case dataItem(dataItem: TextCheckingDataItem) case mention(mentionItem: MentionItem) case referencedUser(referencedUserItem: ReferencedUserItem) case unrevealedSpoiler(UnrevealedSpoilerItem) + case deleteAuthor(deleteAuthorItem: DeleteAuthorItem) public var range: NSRange { switch self { @@ -71,6 +84,8 @@ public class CVTextLabel: NSObject { return referencedUserItem.range case .unrevealedSpoiler(let item): return item.range + case .deleteAuthor(let deleteAuthorItem): + return deleteAuthorItem.range } } @@ -84,6 +99,8 @@ public class CVTextLabel: NSObject { return ".referencedUser" case .unrevealedSpoiler: return ".unrevealedSpoiler" + case .deleteAuthor: + return ".deleteAuthor" } } } @@ -263,7 +280,7 @@ public class CVTextLabel: NSObject { let range = item.range switch item { - case .mention, .referencedUser, .unrevealedSpoiler: + case .mention, .referencedUser, .unrevealedSpoiler, .deleteAuthor: // Do nothing; these are already styled. continue case .dataItem(let dataItem): @@ -468,6 +485,9 @@ public class CVTextLabel: NSObject { case .unrevealedSpoiler: // Don't apply anything for spoilers. return + case .deleteAuthor: + // Don't apply anything for delete author + return } setNeedsDisplay() @@ -659,6 +679,9 @@ extension CVTextLabel.Label: UIDragInteractionDelegate { case .unrevealedSpoiler: // Dragging is not applicable for spoilers. return [] + case .deleteAuthor: + // Dragging is not applicable for admin delete author. + return [] case .dataItem(let dataItem): animate(selectedItem: selectedItem)