Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions bitchat/Services/UnifiedPeerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,17 +251,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return peerIndex[peerID]
}

/// Get peer ID for nickname
/// Get peer ID for nickname.
///
/// An unsuffixed name is accepted only when it identifies exactly one peer.
/// Colliding nicknames must be disambiguated with the people-list `#xxxx`
/// suffix (first four hex characters of the peer ID).
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.normalizedNickname == target || peer.nickname.normalizedNickname == target {
return peer.peerID
guard let id = NicknameLookup.uniquePeerIDString(
for: nickname,
peers: peers.map { peer in
(id: peer.peerID.id, nickname: peer.nickname, displayName: peer.displayName)
}
) else {
return nil
}
return nil
return peers.first(where: { $0.peerID.id == id })?.peerID
}

/// Check if peer is blocked
Expand Down
29 changes: 29 additions & 0 deletions bitchat/Utils/NicknameLookup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// Resolve a typed nickname to a peer ID string.
///
/// An unsuffixed name is accepted only when it identifies exactly one peer.
/// Colliding nicknames must be disambiguated with the people-list `#xxxx`
/// suffix (first four hex characters of the peer ID).
enum NicknameLookup {
static func uniquePeerIDString(
for query: String,
peers: [(id: String, nickname: String, displayName: String)]
) -> String? {
let target = query.normalizedNickname
var matches: [String] = []
for peer in peers {
let nick = peer.nickname.normalizedNickname
let display = peer.displayName.normalizedNickname
let prefix = String(peer.id.prefix(4))
let suffixedNick = (nick + "#" + prefix).normalizedNickname
let suffixedDisplay = (display + "#" + prefix).normalizedNickname
if display == target || nick == target || suffixedNick == target || suffixedDisplay == target {
if !matches.contains(peer.id) {
matches.append(peer.id)
}
}
}
return matches.count == 1 ? matches.first : nil
}
}
21 changes: 14 additions & 7 deletions bitchat/ViewModels/ChatPeerIdentityCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -518,24 +518,31 @@ final class ChatPeerIdentityCoordinator {
let nickname = nickname.normalizedNickname
switch context.activeChannel {
case .location:
if nickname.contains("#"),
let person = context.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
if nickname.contains("#") {
let people = context.visibleGeohashPeople().filter {
$0.displayName.normalizedNickname == nickname
}
if people.count == 1, let person = people.first {
let conversationKey = PeerID(nostr_: person.id)
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
}

let base = nickname
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let geoMatches = context.geoNicknames.filter { $0.value.lowercased() == base }
if geoMatches.count == 1, let pubkey = geoMatches.keys.first {
let conversationKey = PeerID(nostr_: pubkey)
context.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
if geoMatches.count > 1 {
return nil
}

case .mesh:
break
Expand Down
21 changes: 21 additions & 0 deletions bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,27 @@ struct ChatPeerIdentityCoordinatorContextTests {
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}

@Test @MainActor
func getPeerIDForNickname_inGeohashChannel_refusesAmbiguousBaseName() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let aliceA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
let aliceB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.geohashPeople = [
GeoPerson(id: aliceA, displayName: "alice#aaaa", lastSeen: Date()),
GeoPerson(id: aliceB, displayName: "alice#bbbb", lastSeen: Date())
]
context.geoNicknames[aliceA] = "alice"
context.geoNicknames[aliceB] = "alice"
context.peerIDsByNickname["alice"] = PeerID(str: "1122334455667788")

#expect(coordinator.getPeerIDForNickname("alice") == nil)
#expect(coordinator.getPeerIDForNickname("alice#aaaa") == PeerID(nostr_: aliceA))
#expect(coordinator.getPeerIDForNickname("alice#bbbb") == PeerID(nostr_: aliceB))
}

@Test @MainActor
func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async {
let context = MockChatPeerIdentityContext()
Expand Down
32 changes: 32 additions & 0 deletions bitchatTests/Services/UnifiedPeerServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,38 @@ struct UnifiedPeerServiceTests {
#expect(fingerprint == "fp-1")
}

@Test @MainActor
func getPeerID_acceptsAUniqueNickname() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)

let alice = PeerID(str: "1111111111111111")
transport.simulateConnect(alice, nickname: "alice")
service.didUpdatePeerSnapshots(transport.currentPeerSnapshots())

#expect(service.getPeerID(for: "alice") == alice)
}

@Test @MainActor
func getPeerID_refusesAmbiguousNicknameUnlessSuffixed() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)

let aliceA = PeerID(str: "1111111111111111")
let aliceB = PeerID(str: "2222222222222222")
transport.simulateConnect(aliceA, nickname: "alice")
transport.simulateConnect(aliceB, nickname: "alice")
service.didUpdatePeerSnapshots(transport.currentPeerSnapshots())

#expect(service.getPeerID(for: "alice") == nil)
#expect(service.getPeerID(for: "alice#1111") == aliceA)
#expect(service.getPeerID(for: "alice#2222") == aliceB)
}

@Test @MainActor
func isBlocked_usesSocialIdentity() async {
let transport = MockTransport()
Expand Down
Loading