From 8de70cf50fe553d269ab3d02fe06802bad560999 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:39:43 +0300 Subject: [PATCH 1/4] 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")]) + } } From 71eeaf689f577dd800e565b79bf07437d02dce9a Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:40:14 +0300 Subject: [PATCH 2/4] fix: hide the open DM from the recent-messages section Skip the currently selected private peer so the people sheet doesn't list the thread you're already in. --- bitchat/App/PeerListModel.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index e7af0856..649d99b6 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -160,6 +160,13 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + conversations.$selectedPrivatePeerID + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + chatViewModel.groupStore.$groups .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -283,8 +290,11 @@ final class PeerListModel: ObservableObject { 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 } let messages = messagesByPeer[peerID] ?? [] guard let last = messages.last else { return nil } let preview = last.content From 9832470868f0a39a8d693f1c4214a61c2acc4f6e Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 17:59:30 +0300 Subject: [PATCH 3/4] Address Jack review on recent DM threads in people sheet (#1576). --- bitchat/App/PeerListModel.swift | 51 +++++++++++++---- bitchat/Localizable.xcstrings | 59 ++++++++++---------- bitchat/Views/RecentDirectMessagesList.swift | 7 ++- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 649d99b6..0bb74966 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -167,6 +167,16 @@ final class PeerListModel: ObservableObject { } .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 @@ -274,7 +284,9 @@ final class PeerListModel: ObservableObject { self.groupRows = groupRows renderID = ( recentDirectRows.map { - "recent:\($0.id)-\($0.hasUnread)-\($0.displayName)-\($0.preview)" + // 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)" @@ -295,25 +307,32 @@ final class PeerListModel: ObservableObject { 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 = last.content - .trimmingCharacters(in: .whitespacesAndNewlines) - let clipped: String - if preview.count > 48 { - clipped = String(preview.prefix(45)) + "…" - } else { - clipped = preview - } + let preview = Self.friendlyRecentPreview(last.content) return RecentDirectRow( peerID: peerID, displayName: chatViewModel.nicknameForPeer(peerID), hasUnread: chatViewModel.hasUnreadMessages(for: peerID), - preview: clipped + preview: preview ) } } + /// Strip media placeholder filenames (`[image] .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 @@ -352,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) + } +} diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 67b9e841..3e72e23c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -39029,25 +39029,24 @@ } }, "content.people.recent_messages" : { - "comment" : "People sheet section header for recent private message threads", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "الرسائل الأخيرة" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "সাম্প্রতিক বার্তা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "letzte nachrichten" } }, "en" : { @@ -39059,157 +39058,157 @@ "es" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "mensajes recientes" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "پیام‌های اخیر" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "mga kamakailang mensahe" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "messages récents" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "הודעות אחרונות" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "हाल के संदेश" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "pesan terbaru" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "messaggi recenti" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "最近のメッセージ" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "최근 메시지" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "mesej terbaru" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "हालका सन्देशहरू" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "recente berichten" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "ostatnie wiadomości" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "mensagens recentes" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "mensagens recentes" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "недавние сообщения" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "senaste meddelanden" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "சமீபத்திய செய்திகள்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "ข้อความล่าสุด" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "son mesajlar" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "нещодавні повідомлення" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "حالیہ پیغامات" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "tin nhắn gần đây" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "最近的消息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "recent messages" + "value" : "最近的訊息" } } } diff --git a/bitchat/Views/RecentDirectMessagesList.swift b/bitchat/Views/RecentDirectMessagesList.swift index 5a996011..e1dd275c 100644 --- a/bitchat/Views/RecentDirectMessagesList.swift +++ b/bitchat/Views/RecentDirectMessagesList.swift @@ -1,4 +1,5 @@ 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). @@ -49,7 +50,11 @@ struct RecentDirectMessagesList: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(row.displayName) + .accessibilityLabel( + row.hasUnread + ? "\(row.displayName), \(Strings.unread)" + : row.displayName + ) .accessibilityHint(Strings.openDMHint) } }