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..0bb74966 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,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] .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) + } +} diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f869ab85..3e72e23c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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" : { 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..e1dd275c --- /dev/null +++ b/bitchat/Views/RecentDirectMessagesList.swift @@ -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) + } + } + } +} 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")]) + } }