Merge 2ee2c8201bca2879214665638e494e13082f69b1 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-06 01:44:53 +00:00 committed by GitHub
commit b28b02be39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 391 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,30 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
conversations.$conversationIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
// New/updated messages in an existing thread must refresh ordering and
// preview while the people sheet is open conversationIDs alone does
// not change for those mutations.
conversations.changes
.debounce(for: .milliseconds(120), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@ -239,14 +273,21 @@ 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 {
// Hash the preview so a `|` inside message text cannot collide
// with the render-id separator and skip a re-render.
"recent:\($0.id)-\($0.hasUnread)-\($0.displayName)-\($0.preview.stableRenderHash)"
} +
meshRows.map {
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
@ -259,6 +300,39 @@ final class PeerListModel: ObservableObject {
).joined(separator: "|")
}
private func buildRecentDirectRows(excluding myPeerID: PeerID) -> [RecentDirectRow] {
let messagesByPeer = conversations.directMessagesByRoutingPeerID()
let selected = conversations.selectedPrivatePeerID
return conversations.recentDirectRoutingPeerIDs(limit: 8).compactMap { peerID in
guard peerID != myPeerID else { return nil }
// Already in that thread no need to list it again here.
guard peerID != selected else { return nil }
// Match mesh rows: blocked people stay out of the people sheet.
guard !chatViewModel.isPeerBlocked(peerID) else { return nil }
let messages = messagesByPeer[peerID] ?? []
guard let last = messages.last else { return nil }
let preview = Self.friendlyRecentPreview(last.content)
return RecentDirectRow(
peerID: peerID,
displayName: chatViewModel.nicknameForPeer(peerID),
hasUnread: chatViewModel.hasUnreadMessages(for: peerID),
preview: preview
)
}
}
/// Strip media placeholder filenames (`[image] <uuid>.jpg`) down to a
/// short type marker so the recent-DM preview stays readable.
private static func friendlyRecentPreview(_ content: String) -> String {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("[image]") { return "[image]" }
if trimmed.hasPrefix("[voice]") { return "[voice]" }
if trimmed.count > 48 {
return String(trimmed.prefix(45)) + ""
}
return trimmed
}
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in
@ -297,3 +371,15 @@ final class PeerListModel: ObservableObject {
return identity.publicKeyHex.lowercased()
}
}
private extension String {
/// Stable within-process digest for render-id segments avoids embedding
/// raw message text (and `|` separators) in the joined render token.
var stableRenderHash: String {
var hash: UInt64 = 5381
for byte in utf8 {
hash = ((hash << 5) &+ hash) &+ UInt64(byte)
}
return String(hash, radix: 16)
}
}

View File

@ -39028,7 +39028,192 @@
}
}
},
"content.header.people" : {
"content.people.recent_messages" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "الرسائل الأخيرة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "সাম্প্রতিক বার্তা"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "letzte nachrichten"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "recent messages"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "mensajes recientes"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیام‌های اخیر"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "mga kamakailang mensahe"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "messages récents"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "הודעות אחרונות"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "हाल के संदेश"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "pesan terbaru"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "messaggi recenti"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "最近のメッセージ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "최근 메시지"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "mesej terbaru"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "हालका सन्देशहरू"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "recente berichten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "ostatnie wiadomości"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "mensagens recentes"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "mensagens recentes"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "недавние сообщения"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "senaste meddelanden"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "சமீபத்திய செய்திகள்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ข้อความล่าสุด"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "son mesajlar"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "нещодавні повідомлення"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "حالیہ پیغامات"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tin nhắn gần đây"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "最近的消息"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "最近的訊息"
}
}
}
},
"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,62 @@
import SwiftUI
import BitFoundation
/// 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.hasUnread
? "\(row.displayName), \(Strings.unread)"
: 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")])
}
}