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>
This commit is contained in:
jack 2026-08-01 18:41:07 +03:00
parent 681c180060
commit 71c2ea2a93
5 changed files with 631 additions and 0 deletions

View File

@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable {
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
@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject {
}
.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
@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject {
let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
let recentChatRows = buildRecentChatRows(meshRows: meshRows)
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject {
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)"
@ -255,10 +283,82 @@ final class PeerListModel: ObservableObject {
} +
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]) -> [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)
}
}
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: chatViewModel.resolveNickname(for: candidate.peerID),
hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID),
lastActivity: candidate.lastActivity
)
}
}
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

View File

@ -2047,6 +2047,378 @@
}
}
},
"chats.accessibility.open_hint" : {
"comment" : "Accessibility hint on a recent chat row explaining activation opens the direct conversation",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يفتح هذه المحادثة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এই কথোপকথন খোলে"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "öffnet diese unterhaltung"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "opens this conversation"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "abre esta conversación"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "این گفتگو را باز می‌کند"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "binubuksan ang usapang ito"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "ouvre cette conversation"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "פותח את השיחה הזו"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "यह वार्तालाप खोलता है"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "membuka percakapan ini"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "apre questa conversazione"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この会話を開きます"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 대화를 엽니다"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "membuka perbualan ini"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यो वार्तालाप खोल्छ"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "opent dit gesprek"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "otwiera tę rozmowę"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "abre esta conversa"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "abre esta conversa"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "открывает этот разговор"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "öppnar den här konversationen"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இந்த உரையாடலைத் திறக்கும்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เปิดการสนทนานี้"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bu konuşmayı açar"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "відкриває цю розмову"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "یہ گفتگو کھولتا ہے"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "mở cuộc trò chuyện này"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开此对话"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "開啟此對話"
}
}
}
},
"chats.section.header" : {
"comment" : "Section header above recent direct conversations in the people sheet",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "الدردشات"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চ্যাট"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "chats"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "chats"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "chats"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "گفتگوها"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "mga chat"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "discussions"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "צ'אטים"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "चैट"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "obrolan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "chat"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "チャット"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "채팅"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "sembang"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "च्याटहरू"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "chats"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "czaty"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "conversas"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "conversas"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "чаты"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "chattar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "அரட்டைகள்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "แชท"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "sohbetler"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "чати"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "چیٹس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "trò chuyện"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "聊天"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "聊天"
}
}
}
},
"content.system.media_delete_refused" : {
"comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept",
"extractionState" : "manual",

View File

@ -365,6 +365,16 @@ private struct ContentPeopleListView: View {
showSidebar = true
}
)
// Direct conversations survive channel switches; the
// geoDM someone opened from another cell must stay
// reachable here too.
RecentChatList(
chats: peerListModel.recentChatRows,
onTapChat: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
} else {
PeopleSectionHeader(
icon: "antenna.radiowaves.left.and.right",
@ -400,6 +410,16 @@ private struct ContentPeopleListView: View {
showSidebar = true
}
)
// Conversations with people no roster above lists
// anymore without this, a read DM from an offline
// non-favorite had no row anywhere in the UI.
RecentChatList(
chats: peerListModel.recentChatRows,
onTapChat: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
}
}
.padding(.top, 4)

View File

@ -0,0 +1,89 @@
//
// RecentChatList.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import SwiftUI
/// "chats" section for the people sheet: direct conversations with people
/// who are not in any roster above it (offline passersby, geoDMs from a
/// channel since left). Before this section existed, those threads were
/// unreachable the moment the unread envelope cleared still in memory,
/// no row anywhere in the UI. Renders nothing when there are none.
struct RecentChatList: View {
@ThemedPalette private var palette
let chats: [RecentChatRow]
let onTapChat: (PeerID) -> Void
private enum Strings {
static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation")
}
/// Relative "5 min ago" stamps; the formatter is locale-aware.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
var body: some View {
if !chats.isEmpty {
VStack(alignment: .leading, spacing: 0) {
// Same glyph+label header shape as #mesh / groups.
PeopleSectionHeader(
icon: "bubble.left.and.bubble.right",
iconColor: palette.secondary,
title: Strings.header
)
ForEach(chats) { chat in
HStack(spacing: 4) {
Text(verbatim: chat.displayName)
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
.truncationMode(.tail)
Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary.opacity(0.8))
Spacer()
if chat.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
.help(Strings.newMessagesTooltip)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapChat(chat.peerID) }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: chat))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openChatHint)
}
}
}
}
private func accessibilityDescription(for chat: RecentChatRow) -> String {
var parts: [String] = [
chat.displayName,
Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())
]
if chat.hasUnread { parts.append(Strings.unread) }
return parts.joined(separator: ", ")
}
}

View File

@ -458,6 +458,56 @@ struct AppArchitectureTests {
#expect(chromeModel.showingFingerprintFor == nil)
}
@Test("Recent chats list direct conversations with people absent from the rosters")
@MainActor
func peerListModelSurfacesRecentChatsForAbsentPeers() async {
let viewModel = makeArchitectureViewModel()
let offlinePeerID = PeerID(str: "00000000000000aa")
let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16))
// A DM thread from a passerby who has since gone offline: not in
// allPeers, not a favorite previously unreachable once read.
viewModel.seedPrivateChat([
BitchatMessage(
id: "dm-1",
sender: "passerby",
content: "hello from the train",
timestamp: Date(timeIntervalSince1970: 100),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: offlinePeerID
)
], for: offlinePeerID)
// Group threads have their own section and must not duplicate here.
viewModel.seedPrivateChat([
BitchatMessage(
id: "gm-1",
sender: "member",
content: "group hello",
timestamp: Date(timeIntervalSince1970: 200),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: groupPeerID
)
], for: groupPeerID)
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversations: viewModel.conversations
)
await waitUntil {
peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID }
}
#expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID])
#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 })
}
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
@MainActor
func privateConversationModelResolvesSelectedHeaderState() async {