Review fixes: geo dedup and geoDM names in recent chats

- A person still visible in the geohash roster could also get a recent
  chat row (the builder only subtracted mesh rows). Visible geohash
  pubkeys now join the identity set via their PeerID(nostr_:) form, so
  the absent-from-rosters contract holds on location channels too.
- GeoDM rows went through resolveNickname, which only consults mesh
  identity sources and rendered nostr_… keys as an opaque anon
  fallback. They now resolve through geohashDisplayName (Nostr key
  mapping, bare-key fallback).

Both pinned in the recent-chats architecture test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-09 12:01:45 +02:00
parent 71c2ea2a93
commit 1f348c1e8c
2 changed files with 56 additions and 3 deletions

View File

@ -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<String>()
@ -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, _),

View File

@ -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")