diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift index b4f6aaef..b67731ba 100644 --- a/bitchat/App/LocationPresenceStore.swift +++ b/bitchat/App/LocationPresenceStore.swift @@ -36,6 +36,7 @@ final class LocationPresenceStore: ObservableObject { return } + let nickname = nickname.normalizedNickname let key = pubkeyHex.lowercased() if geoNicknames[key] != nil { geoNicknames[key] = nickname @@ -64,7 +65,7 @@ final class LocationPresenceStore: ObservableObject { let lower = key.lowercased() guard seen.insert(lower).inserted else { continue } ordered.append(lower) - normalized[lower] = value + normalized[lower] = value.normalizedNickname } if ordered.count > geoNicknameCapacity { let kept = Array(ordered.suffix(geoNicknameCapacity)) diff --git a/bitchat/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift index 9ae47df9..6c55f34e 100644 --- a/bitchat/Services/AutocompleteService.swift +++ b/bitchat/Services/AutocompleteService.swift @@ -55,10 +55,10 @@ final class AutocompleteService { let fullRange = match.range(at: 0) let captureRange = match.range(at: 1) - let prefix = nsText.substring(with: captureRange).lowercased() - + let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased() + let suggestions = peers - .filter { $0.lowercased().hasPrefix(prefix) } + .filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) } .sorted() .prefix(5) .map { "@\($0)" } diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 679b419e..62113a59 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -223,7 +223,7 @@ struct BLEPeerRegistry { peers[peerID] = BLEPeerInfo( peerID: existing?.peerID ?? peerID, - nickname: nickname, + nickname: nickname.normalizedNickname, isConnected: isConnected, noisePublicKey: noisePublicKey, // Never drop an already-pinned signing key. diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 8f77bfa5..88a32e3c 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -110,11 +110,12 @@ final class MessageFormattingEngine { ) // Format content + let myNickname = context.nickname.normalizedNickname let contentResult = formatContent( message.content, baseColor: baseColor, isSelf: isSelf, - isMentioned: message.mentions?.contains(context.nickname) ?? false + isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false ) result.append(contentResult) diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 879dc866..c22c7e43 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -236,8 +236,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { /// Get peer ID for nickname func getPeerID(for nickname: String) -> PeerID? { + // Normalize both sides: the query may come from typed content and + // stored names may predate NFC-at-ingest (e.g. persisted favorites). + let target = nickname.normalizedNickname for peer in peers { - if peer.displayName == nickname || peer.nickname == nickname { + if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target { return peer.peerID } } diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift index e9c86868..929cfee5 100644 --- a/bitchat/Utils/InputValidator.swift +++ b/bitchat/Utils/InputValidator.swift @@ -39,9 +39,10 @@ struct InputValidator { return trimmed } - /// Validates nickname + /// Validates nickname and returns it in canonical (NFC) form so + /// visually identical names always compare equal. static func validateNickname(_ nickname: String) -> String? { - return validateUserString(nickname, maxLength: Limits.maxNicknameLength) + return validateUserString(nickname, maxLength: Limits.maxNicknameLength)?.normalizedNickname } // MARK: - Protocol Field Validation diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift index b586aa66..9a652667 100644 --- a/bitchat/Utils/String+Nickname.swift +++ b/bitchat/Utils/String+Nickname.swift @@ -9,6 +9,14 @@ import Foundation extension String { + /// Canonical form for nickname storage and comparison (Unicode NFC). + /// "café" typed with a combining accent and "café" typed precomposed + /// must resolve to the same user wherever nicknames are stored or + /// matched (mentions, DM resolution, autocomplete, geo presence). + var normalizedNickname: String { + precomposedStringWithCanonicalMapping + } + /// Split a nickname into base and a '#abcd' suffix if present func splitSuffix() -> (String, String) { let name = self.replacingOccurrences(of: "@", with: "") diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index a2259b08..ae663f84 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -188,7 +188,8 @@ final class ChatMessageFormatter { allMatches.sort { $0.range.location < $1.range.location } var lastEnd = content.startIndex - let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false + let myNickname = viewModel.nickname.normalizedNickname + let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false for (range, type) in allMatches { guard let swiftRange = Range(range, in: content) else { continue } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 93b0f32a..65b30df9 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -501,6 +501,9 @@ final class ChatPeerIdentityCoordinator { @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { + // Queries arrive from typed commands and message content, so bring + // them to the same canonical (NFC) form nicknames are stored in. + let nickname = nickname.normalizedNickname switch context.activeChannel { case .location: if nickname.contains("#"), diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 301c4bee..d12e2c1d 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -506,14 +506,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func checkForMentions(_ message: BitchatMessage) { - var myTokens: Set = [context.nickname] + let myNickname = context.nickname.normalizedNickname + var myTokens: Set = [myNickname] let meshPeers = context.meshPeerNicknames() - let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") } + let collisions = meshPeers.values.filter { $0.normalizedNickname.hasPrefix(myNickname + "#") } if !collisions.isEmpty { let suffix = "#" + String(context.myPeerID.id.prefix(4)) - myTokens = [context.nickname + suffix] + myTokens = [myNickname + suffix] } - let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false + let isMentioned = message.mentions?.contains { myTokens.contains($0.normalizedNickname) } ?? false if isMentioned && message.sender != context.nickname { SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d794ea3f..12adfda9 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -176,10 +176,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage var networkActivationAllowed: Bool { !panicRecoveryBlocked } @Published var nickname: String = "" { didSet { - // Trim whitespace whenever nickname is set; whitespace-only becomes "" - let trimmed = nickname.trimmedOrNilIfEmpty ?? "" - if trimmed != nickname { - nickname = trimmed + // Canonicalize whenever nickname is set: trim whitespace + // (whitespace-only becomes "") and apply Unicode NFC so accented + // names match regardless of how they were typed. + let cleaned = (nickname.trimmedOrNilIfEmpty ?? "").normalizedNickname + if cleaned != nickname { + nickname = cleaned return } // Update mesh service nickname if it's initialized diff --git a/bitchatTests/NicknameNormalizationTests.swift b/bitchatTests/NicknameNormalizationTests.swift new file mode 100644 index 00000000..a32225b9 --- /dev/null +++ b/bitchatTests/NicknameNormalizationTests.swift @@ -0,0 +1,53 @@ +// +// NicknameNormalizationTests.swift +// bitchatTests +// +// Nicknames must compare equal regardless of how the user's keyboard +// produced them: "café" as precomposed U+00E9 and as "e" + combining +// U+0301 are canonically equivalent but bytewise different, which broke +// mention matching, DM resolution, and autocomplete (#214). Storage and +// comparison both canonicalize to NFC via String.normalizedNickname. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct NicknameNormalizationTests { + /// "café" with a combining acute accent (NFD form) + private let decomposed = "cafe\u{0301}" + /// "café" with precomposed é (NFC form) + private let precomposed = "caf\u{00E9}" + + @Test + func canonicallyEquivalentFormsNormalizeIdentically() { + // Sanity: the raw forms really are different strings byte-wise … + #expect(decomposed.unicodeScalars.count != precomposed.unicodeScalars.count) + // … and normalization unifies them. + #expect(decomposed.normalizedNickname == precomposed.normalizedNickname) + #expect(decomposed.normalizedNickname == precomposed) + } + + @Test + func asciiNicknamesPassThroughUnchanged() { + #expect("alice_42".normalizedNickname == "alice_42") + #expect("".normalizedNickname == "") + } + + @Test + func validateNicknameReturnsCanonicalForm() { + #expect(InputValidator.validateNickname(decomposed) == precomposed) + #expect(InputValidator.validateNickname(" \(decomposed) ") == precomposed) + // Validation behavior is otherwise unchanged. + #expect(InputValidator.validateNickname(" ") == nil) + } + + @Test + func collisionSuffixSplittingSurvivesNormalization() { + let (base, suffix) = (decomposed.normalizedNickname + "#ab12").splitSuffix() + #expect(base == precomposed) + #expect(suffix == "#ab12") + } +}