bitchat/bitchat/App/PeerListModel.swift
jack c6cb4210e4
Add a recent-chats section so DM conversations can't become unreachable (#1598)
* Add a recent-chats section so DM conversations can't become unreachable

The largest IA hole from the design/UX audit: a direct conversation
with someone who went offline (and isn't a mutual favorite) had no row
anywhere in the UI. The roster filters to connected/reachable/mutual-
favorite, the header envelope only exists while unread, and /msg can't
resolve offline strangers — read a DM from a passerby, close the
sheet, and the thread was still in memory with no way back to it.

- PeerListModel now derives RecentChatRow entries from the
  ConversationStore's direct conversations: people absent from the
  rosters above, newest activity first, unread flag, deduplicated by
  fingerprint across ephemeral/stable peer-ID mirrors (newest wins),
  groups and blocked peers excluded.
- A "chats" section (same header shape as #mesh/groups) renders them
  in the people sheet on both mesh and location channels — geoDMs from
  a channel since left stay reachable too. Tapping reopens the DM via
  the existing startConversation path; the section renders nothing
  when empty.
- Rebuilds are driven by direct-conversation store changes only, so
  public-timeline traffic doesn't churn the sheet.

2 new strings, all 30 locales. New architecture test pins that an
offline stranger's thread gets a row while group threads don't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:58:18 +02:00

419 lines
16 KiB
Swift

import BitFoundation
import Combine
import SwiftUI
struct MeshPeerRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let isMe: Bool
let hasUnread: Bool
let isBlocked: Bool
let isFavorite: Bool
let isConnected: Bool
let isReachable: Bool
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
/// Vouched-for by someone I verified, without an explicit verification of
/// mine rendered as the unfilled seal (verified gets the filled one).
let showsVouchedBadge: Bool
var id: String { peerID.id }
}
struct GeohashPersonRow: Identifiable, Equatable {
let id: String
let displayName: String
let isMe: Bool
let isTeleported: Bool
let isBlocked: Bool
}
struct GroupChatRow: Identifiable, Equatable {
let peerID: PeerID
let name: String
let memberCount: Int
let isCreator: Bool
let hasUnread: Bool
var id: String { peerID.id }
}
/// A direct conversation whose person is NOT in any roster above it. Without
/// this section, a DM from a passerby became unreachable the moment they went
/// offline: the roster filters to connected/reachable/mutual-favorite, the
/// header envelope only exists while unread, and /msg can't resolve offline
/// strangers the thread was still in memory with no row anywhere in the UI.
struct RecentChatRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let hasUnread: Bool
let lastActivity: Date
var id: String { peerID.id }
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var recentChatRows: [RecentChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
self.allPeers = chatViewModel.allPeers
bind()
refresh()
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
}
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
}
func participantCount(for geohash: String) -> Int {
chatViewModel.geohashParticipantCount(for: geohash)
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}
func openGeohashDirectMessage(with pubkeyHex: String) {
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.blockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.unblockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
private func bind() {
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
self?.allPeers = peers
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationPresenceStore.$teleportedGeo
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversations.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
// Direct-conversation changes reorder or create recent-chat rows.
// Filtered to `.direct` so public-timeline traffic (every mesh or
// geohash message emits a change) doesn't rebuild the sheet.
conversations.changes
.receive(on: DispatchQueue.main)
.sink { [weak self] change in
guard case .direct = Self.changedConversationID(change) else { return }
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
private func refresh() {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
// Vouched is subordinate to verified: never show both seals.
let vouchedBadge = !isVerifiedFingerprint
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.displayName,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isConnected: peer.isConnected,
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge,
showsVouchedBadge: vouchedBadge
)
}
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
guard !row.isMe else { return }
if row.isConnected {
counts.connected += 1
counts.reachable += 1
} else if row.isReachable {
counts.reachable += 1
}
}
let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
self.recentChatRows = recentChatRows
renderID = (
meshRows.map {
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
} +
groupRows.map {
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
} +
recentChatRows.map {
"chat:\($0.id)-\($0.displayName)-\($0.hasUnread)"
}
).joined(separator: "|")
}
/// Direct conversations with people absent from the rosters above:
/// the offline passerby DM, the geoDM from a channel since left. Rows
/// 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],
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>()
for row in meshRows {
visibleIdentities.insert(row.peerID.id)
if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) {
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
let lastActivity: Date
}
var bestByIdentity: [String: Candidate] = [:]
for (id, conversation) in conversations.conversationsByID {
guard case .direct(let handle) = id else { continue }
let peerID = handle.routingPeerID
// Groups have their own section; blocked people get no row.
guard !peerID.isGroup else { continue }
guard let lastMessage = conversation.messages.last else { continue }
guard !chatViewModel.isPeerBlocked(peerID) else { continue }
let fingerprint = chatViewModel.getFingerprint(for: peerID)
if visibleIdentities.contains(peerID.id) { continue }
if let fingerprint, visibleIdentities.contains(fingerprint) { continue }
let identityKey = fingerprint ?? peerID.id
if let existing = bestByIdentity[identityKey],
existing.lastActivity >= lastMessage.timestamp {
continue
}
bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp)
}
return bestByIdentity.values
.sorted { $0.lastActivity > $1.lastActivity }
.map { candidate in
RecentChatRow(
peerID: 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, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id),
.removed(let id),
.unreadChanged(let id, _):
return id
case .migrated(_, let to):
return to
}
}
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in
GroupChatRow(
peerID: group.peerID,
name: group.name,
memberCount: group.members.count,
isCreator: group.creatorFingerprint == myFingerprint,
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
)
}
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
return chatViewModel.visibleGeohashPeople().map { person in
let isMe = person.id == myHex
return GeohashPersonRow(
id: person.id,
displayName: person.displayName,
isMe: isMe,
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
)
}
}
private func currentGeohashIdentityHex() -> String? {
guard case .location(let channel) = locationChannelsModel.selectedChannel,
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
return nil
}
return identity.publicKeyHex.lowercased()
}
}