Make admin name tappable

This commit is contained in:
kate-signal 2026-02-23 15:21:45 -05:00 committed by GitHub
parent 58cdc81e23
commit 82fab30fcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 147 additions and 28 deletions

View File

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

View File

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

View File

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

View File

@ -365,6 +365,8 @@ class LongTextViewController: OWSViewController {
)
self.loadContent()
return
case .deleteAuthor:
owsFailDebug("delete author should not appear in long message body")
}
}
}

View File

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

View File

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