From 8de70cf50fe553d269ab3d02fe06802bad560999 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:39:43 +0300 Subject: [PATCH] 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. --- bitchat/App/ConversationStore.swift | 23 +++ bitchat/App/PeerListModel.swift | 45 +++++ bitchat/Localizable.xcstrings | 188 ++++++++++++++++++- bitchat/Views/ContentSheetViews.swift | 13 ++ bitchat/Views/RecentDirectMessagesList.swift | 57 ++++++ bitchatTests/ConversationStoreTests.swift | 21 +++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 bitchat/Views/RecentDirectMessagesList.swift diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 76e71813..3b55e86e 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -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() + 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 diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..e7af0856 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -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 diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f869ab85..67b9e841 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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" : { diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..dac45329 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -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, diff --git a/bitchat/Views/RecentDirectMessagesList.swift b/bitchat/Views/RecentDirectMessagesList.swift new file mode 100644 index 00000000..5a996011 --- /dev/null +++ b/bitchat/Views/RecentDirectMessagesList.swift @@ -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) + } + } + } +} diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index cc1e6e09..c3a924cd 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -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")]) + } }