diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 2b371f10..4f1fc633 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -265,7 +265,7 @@ final class PeerListModel: ObservableObject { let geohashPeople = buildGeohashPeople() let groupRows = buildGroupRows() - let recentChatRows = buildRecentChatRows(meshRows: meshRows) + let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople) self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable @@ -295,7 +295,10 @@ final class PeerListModel: ObservableObject { /// mirrored under both an ephemeral and a stable peer ID collapse to /// one row per identity (newest activity wins), matching how the /// private-chat coordinators consolidate on open. - private func buildRecentChatRows(meshRows: [MeshPeerRow]) -> [RecentChatRow] { + private func buildRecentChatRows( + meshRows: [MeshPeerRow], + geohashPeople: [GeohashPersonRow] + ) -> [RecentChatRow] { // A conversation can be keyed by the stable Noise peer ID while the // roster lists the ephemeral one — compare fingerprints too. var visibleIdentities = Set() @@ -305,6 +308,12 @@ final class PeerListModel: ObservableObject { visibleIdentities.insert(fingerprint) } } + // Someone visible in the geohash section must not also get a chat + // row: their GeoDM conversation is keyed by the nostr_ form of the + // same pubkey the roster lists. + for person in geohashPeople { + visibleIdentities.insert(PeerID(nostr_: person.id).id) + } struct Candidate { let peerID: PeerID @@ -337,13 +346,23 @@ final class PeerListModel: ObservableObject { .map { candidate in RecentChatRow( peerID: candidate.peerID, - displayName: chatViewModel.resolveNickname(for: candidate.peerID), + displayName: displayName(for: candidate.peerID), hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), lastActivity: candidate.lastActivity ) } } + /// GeoDM conversations resolve through the Nostr mapping — + /// `resolveNickname` only consults mesh identity sources and would + /// render a `nostr_…` key as an opaque `anonnost` fallback. + private func displayName(for peerID: PeerID) -> String { + if peerID.isGeoDM { + return chatViewModel.geohashDisplayName(for: peerID) + } + return chatViewModel.resolveNickname(for: peerID) + } + private static func changedConversationID(_ change: ConversationChange) -> ConversationID { switch change { case .appended(let id, _), diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index ccb42d21..360c89ab 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -506,6 +506,40 @@ struct AppArchitectureTests { #expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100)) // The roster doesn't list this peer — that's exactly why the row exists. #expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID }) + + // A geoDM thread resolves through the Nostr mapping (bare-key + // fallback here), never resolveNickname's mesh-only anon fallback. + let pubkeyHex = String(repeating: "ab", count: 32) + let geoDMPeer = PeerID(nostr_: pubkeyHex) + viewModel.seedPrivateChat([ + BitchatMessage( + id: "geo-1", + sender: "stranger", + content: "hello from another cell", + timestamp: Date(timeIntervalSince1970: 300), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: geoDMPeer + ) + ], for: geoDMPeer) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + let geoRow = peerListModel.recentChatRows.first { $0.peerID == geoDMPeer } + #expect(geoRow?.displayName == geoDMPeer.bare) + + // While that person is visible in the geohash roster, the chat row + // collapses — same absent-from-rosters contract as mesh. + viewModel.participantTracker.setActiveGeohash("u4pruy") + viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") + + await waitUntil { + !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) } @Test("PrivateConversationModel resolves canonical header state for the selected DM")