Merge 7162d3f451e6379173619df8c8fddb0ae58d277d into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-11 09:05:57 +02:00 committed by GitHub
commit cab7a04bfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 29 additions and 6 deletions

View File

@ -41,7 +41,9 @@ final class MessageFormattingEngine {
enum Patterns {
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
// Include \p{M} so NFD nicknames (base letter + combining mark) still
// parse as one mention token; NFC forms are covered by \p{L} alone.
static let mention = SafeRegex.compile("@([\\p{L}\\p{M}0-9_]+(?:#[a-fA-F0-9]{4})?)")
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")

View File

@ -116,6 +116,9 @@ final class ChatComposerCoordinator {
}
func parseMentions(from content: String) -> [String] {
// Canonicalize before matching so NFD typed text and NFC peer tokens
// share one codepoint sequence (regex also accepts combining marks).
let content = content.precomposedStringWithCanonicalMapping
let regex = ChatViewModel.Patterns.mention
let nsContent = content as NSString
let matches = regex.matches(
@ -125,16 +128,22 @@ final class ChatComposerCoordinator {
)
let peerNicknames = context.meshPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(context.nickname)
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
// Key by NFC so a typed combining-accent form still resolves to the
// stored (precomposed) nickname token used on the wire.
var tokensByCanonical = [String: String]()
for token in peerNicknames.values {
tokensByCanonical[token.normalizedNickname] = token
}
tokensByCanonical[context.nickname.normalizedNickname] = context.nickname
let selfSuffix = context.nickname + "#" + String(context.myPeerID.id.prefix(4))
tokensByCanonical[selfSuffix.normalizedNickname] = selfSuffix
var mentions: [String] = []
for match in matches {
guard let range = Range(match.range(at: 1), in: content) else { continue }
let mentionedName = String(content[range])
if validTokens.contains(mentionedName) {
mentions.append(mentionedName)
if let canonical = tokensByCanonical[mentionedName.normalizedNickname] {
mentions.append(canonical)
}
}

View File

@ -192,4 +192,16 @@ struct ChatComposerCoordinatorContextTests {
)
#expect(Set(mentions) == ["alice", "me", "me#0011"])
}
@Test @MainActor
func parseMentions_matchesUnicodeEquivalentNicknames() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
let precomposed = "caf\u{00E9}"
let decomposed = "cafe\u{0301}"
context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): precomposed]
let mentions = coordinator.parseMentions(from: "hi @\(decomposed)")
#expect(mentions == [precomposed])
}
}