mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
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:
parent
1f59e814f9
commit
8de70cf50f
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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,
|
||||
|
||||
57
bitchat/Views/RecentDirectMessagesList.swift
Normal file
57
bitchat/Views/RecentDirectMessagesList.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")])
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user