feat: show recent DM threads at the top of the people sheet

Re-scoped #615 gap: reopen past private chats without hunting the mesh
list. Cap at 8, newest first, with unread + last-message preview.
This commit is contained in:
Taksh 2026-07-31 14:39:43 +03:00
parent 1f59e814f9
commit 8de70cf50f
6 changed files with 346 additions and 1 deletions

View File

@ -821,6 +821,29 @@ extension ConversationID {
}
extension ConversationStore {
/// Routing peer IDs for direct conversations with at least one message,
/// newest activity first. Caps at `limit` so the people sheet stays short.
func recentDirectRoutingPeerIDs(limit: Int = 8) -> [PeerID] {
var scored: [(peerID: PeerID, last: Date)] = []
scored.reserveCapacity(conversationsByID.count)
for (id, conversation) in conversationsByID {
guard case .direct(let handle) = id else { continue }
guard let last = conversation.messages.last else { continue }
scored.append((handle.routingPeerID, last.timestamp))
}
scored.sort { $0.last > $1.last }
var seen = Set<PeerID>()
var result: [PeerID] = []
result.reserveCapacity(min(limit, scored.count))
for entry in scored {
guard seen.insert(entry.peerID).inserted else { continue }
result.append(entry.peerID)
if result.count == limit { break }
}
return result
}
/// All direct conversations' messages keyed by routing peer ID the
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// Values are the conversations' backing arrays (COW), so building this

View File

@ -39,10 +39,20 @@ struct GroupChatRow: Identifiable, Equatable {
var id: String { peerID.id }
}
struct RecentDirectRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let hasUnread: Bool
let preview: String
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 recentDirectRows: [RecentDirectRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@ -143,6 +153,13 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
conversations.$conversationIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@ -239,14 +256,19 @@ final class PeerListModel: ObservableObject {
let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
let recentDirectRows = buildRecentDirectRows(excluding: myPeerID)
self.meshRows = meshRows
self.recentDirectRows = recentDirectRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
renderID = (
recentDirectRows.map {
"recent:\($0.id)-\($0.hasUnread)-\($0.displayName)-\($0.preview)"
} +
meshRows.map {
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
@ -259,6 +281,29 @@ final class PeerListModel: ObservableObject {
).joined(separator: "|")
}
private func buildRecentDirectRows(excluding myPeerID: PeerID) -> [RecentDirectRow] {
let messagesByPeer = conversations.directMessagesByRoutingPeerID()
return conversations.recentDirectRoutingPeerIDs(limit: 8).compactMap { peerID in
guard peerID != myPeerID else { return nil }
let messages = messagesByPeer[peerID] ?? []
guard let last = messages.last else { return nil }
let preview = last.content
.trimmingCharacters(in: .whitespacesAndNewlines)
let clipped: String
if preview.count > 48 {
clipped = String(preview.prefix(45)) + ""
} else {
clipped = preview
}
return RecentDirectRow(
peerID: peerID,
displayName: chatViewModel.nicknameForPeer(peerID),
hasUnread: chatViewModel.hasUnreadMessages(for: peerID),
preview: clipped
)
}
}
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in

View File

@ -39028,7 +39028,193 @@
}
}
},
"content.header.people" : {
"content.people.recent_messages" : {
"comment" : "People sheet section header for recent private message threads",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
}
}
},
"content.header.people" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {

View File

@ -366,6 +366,19 @@ private struct ContentPeopleListView: View {
}
)
} else {
if !peerListModel.recentDirectRows.isEmpty {
PeopleSectionHeader(
icon: "bubble.left.and.bubble.right",
iconColor: palette.accentBlue,
title: String(localized: "content.people.recent_messages", defaultValue: "recent messages", comment: "People sheet section header for recent private message threads")
)
RecentDirectMessagesList(
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
}
PeopleSectionHeader(
icon: "antenna.radiowaves.left.and.right",
iconColor: palette.accentBlue,

View File

@ -0,0 +1,57 @@
import SwiftUI
/// Compact recent-DM rows for the people sheet reopen past private threads
/// without hunting through the full mesh / favorites lists (#615).
struct RecentDirectMessagesList: View {
@EnvironmentObject private var peerListModel: PeerListModel
@ThemedPalette private var palette
let onTapPeer: (PeerID) -> Void
private enum Strings {
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(peerListModel.recentDirectRows) { row in
Button {
onTapPeer(row.peerID)
} label: {
HStack(spacing: 8) {
Image(systemName: "person.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(row.displayName)
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
if row.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 9))
.foregroundColor(palette.accentBlue)
.help(Strings.unread)
}
}
if !row.preview.isEmpty {
Text(row.preview)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(row.displayName)
.accessibilityHint(Strings.openDMHint)
}
}
}
}

View File

@ -1419,4 +1419,25 @@ struct ConversationStoreTests {
store.removeMessage(withID: "dm-1", from: destination)
#expect(store.appendCount == 4)
}
@Test("recentDirectRoutingPeerIDs orders by latest message and skips empty threads")
@MainActor
func recentDirectRoutingPeerIDsOrdersByActivity() {
let store = ConversationStore()
let older = makeDirectConversationID("older")
let newer = makeDirectConversationID("newer")
let empty = makeDirectConversationID("empty")
store.append(makeMessage(id: "dm-old", timestamp: 10, isPrivate: true), to: older)
store.append(makeMessage(id: "dm-new", timestamp: 50, isPrivate: true), to: newer)
_ = store.conversation(for: empty) // create empty direct conversation
let recent = store.recentDirectRoutingPeerIDs(limit: 8)
#expect(recent == [
PeerID(str: "peer-newer"),
PeerID(str: "peer-older"),
])
#expect(!recent.contains(PeerID(str: "peer-empty")))
#expect(store.recentDirectRoutingPeerIDs(limit: 1) == [PeerID(str: "peer-newer")])
}
}