diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 88a32e3c..419e9e02 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -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") diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index d0d347f2..0e52b3f3 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -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) } } diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift index d38ddc8a..71272253 100644 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -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]) + } }