From 71c2ea2a930dd2f0bde970fd237368b467b78169 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 1 Aug 2026 18:41:07 +0300 Subject: [PATCH] Add a recent-chats section so DM conversations can't become unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest IA hole from the design/UX audit: a direct conversation with someone who went offline (and isn't a mutual favorite) had no row anywhere in the UI. The roster filters to connected/reachable/mutual- favorite, the header envelope only exists while unread, and /msg can't resolve offline strangers — read a DM from a passerby, close the sheet, and the thread was still in memory with no way back to it. - PeerListModel now derives RecentChatRow entries from the ConversationStore's direct conversations: people absent from the rosters above, newest activity first, unread flag, deduplicated by fingerprint across ephemeral/stable peer-ID mirrors (newest wins), groups and blocked peers excluded. - A "chats" section (same header shape as #mesh/groups) renders them in the people sheet on both mesh and location channels — geoDMs from a channel since left stay reachable too. Tapping reopens the DM via the existing startConversation path; the section renders nothing when empty. - Rebuilds are driven by direct-conversation store changes only, so public-timeline traffic doesn't churn the sheet. 2 new strings, all 30 locales. New architecture test pins that an offline stranger's thread gets a row while group threads don't. Co-Authored-By: Claude Fable 5 --- bitchat/App/PeerListModel.swift | 100 +++++++ bitchat/Localizable.xcstrings | 372 ++++++++++++++++++++++++ bitchat/Views/ContentSheetViews.swift | 20 ++ bitchat/Views/RecentChatList.swift | 89 ++++++ bitchatTests/AppArchitectureTests.swift | 50 ++++ 5 files changed, 631 insertions(+) create mode 100644 bitchat/Views/RecentChatList.swift diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..2b371f10 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable { var id: String { peerID.id } } +/// A direct conversation whose person is NOT in any roster above it. Without +/// this section, a DM from a passerby became unreachable the moment they went +/// offline: the roster filters to connected/reachable/mutual-favorite, the +/// header envelope only exists while unread, and /msg can't resolve offline +/// strangers — the thread was still in memory with no row anywhere in the UI. +struct RecentChatRow: Identifiable, Equatable { + let peerID: PeerID + let displayName: String + let hasUnread: Bool + let lastActivity: Date + + 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 geohashPeople: [GeohashPersonRow] = [] @Published private(set) var groupRows: [GroupChatRow] = [] + @Published private(set) var recentChatRows: [RecentChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + // Direct-conversation changes reorder or create recent-chat rows. + // Filtered to `.direct` so public-timeline traffic (every mesh or + // geohash message emits a change) doesn't rebuild the sheet. + conversations.changes + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + guard case .direct = Self.changedConversationID(change) else { return } + self?.refresh() + } + .store(in: &cancellables) + chatViewModel.groupStore.$groups .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject { let geohashPeople = buildGeohashPeople() let groupRows = buildGroupRows() + let recentChatRows = buildRecentChatRows(meshRows: meshRows) self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable @@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject { self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count self.groupRows = groupRows + self.recentChatRows = recentChatRows renderID = ( meshRows.map { "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" @@ -255,10 +283,82 @@ final class PeerListModel: ObservableObject { } + groupRows.map { "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" + } + + recentChatRows.map { + "chat:\($0.id)-\($0.displayName)-\($0.hasUnread)" } ).joined(separator: "|") } + /// Direct conversations with people absent from the rosters above: + /// the offline passerby DM, the geoDM from a channel since left. Rows + /// mirrored under both an ephemeral and a stable peer ID collapse to + /// one row per identity (newest activity wins), matching how the + /// private-chat coordinators consolidate on open. + private func buildRecentChatRows(meshRows: [MeshPeerRow]) -> [RecentChatRow] { + // A conversation can be keyed by the stable Noise peer ID while the + // roster lists the ephemeral one — compare fingerprints too. + var visibleIdentities = Set() + for row in meshRows { + visibleIdentities.insert(row.peerID.id) + if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) { + visibleIdentities.insert(fingerprint) + } + } + + struct Candidate { + let peerID: PeerID + let lastActivity: Date + } + var bestByIdentity: [String: Candidate] = [:] + + for (id, conversation) in conversations.conversationsByID { + guard case .direct(let handle) = id else { continue } + let peerID = handle.routingPeerID + // Groups have their own section; blocked people get no row. + guard !peerID.isGroup else { continue } + guard let lastMessage = conversation.messages.last else { continue } + guard !chatViewModel.isPeerBlocked(peerID) else { continue } + + let fingerprint = chatViewModel.getFingerprint(for: peerID) + if visibleIdentities.contains(peerID.id) { continue } + if let fingerprint, visibleIdentities.contains(fingerprint) { continue } + + let identityKey = fingerprint ?? peerID.id + if let existing = bestByIdentity[identityKey], + existing.lastActivity >= lastMessage.timestamp { + continue + } + bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp) + } + + return bestByIdentity.values + .sorted { $0.lastActivity > $1.lastActivity } + .map { candidate in + RecentChatRow( + peerID: candidate.peerID, + displayName: chatViewModel.resolveNickname(for: candidate.peerID), + hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), + lastActivity: candidate.lastActivity + ) + } + } + + private static func changedConversationID(_ change: ConversationChange) -> ConversationID { + switch change { + case .appended(let id, _), + .updated(let id, _), + .statusChanged(let id, _, _), + .messageRemoved(let id, _), + .cleared(let id), + .removed(let id), + .unreadChanged(let id, _): + return id + case .migrated(_, let to): + return to + } + } + 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 c43da7a2..1ec57cf5 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2047,6 +2047,378 @@ } } }, + "chats.accessibility.open_hint" : { + "comment" : "Accessibility hint on a recent chat row explaining activation opens the direct conversation", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يفتح هذه المحادثة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই কথোপকথন খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffnet diese unterhaltung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens this conversation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversación" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این گفتگو را باز می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "binubuksan ang usapang ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvre cette conversation" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פותח את השיחה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह वार्तालाप खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka percakapan ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "apre questa conversazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この会話を開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 대화를 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka perbualan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो वार्तालाप खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "opent dit gesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "otwiera tę rozmowę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "открывает этот разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "öppnar den här konversationen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดการสนทนานี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu konuşmayı açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відкриває цю розмову" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ گفتگو کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở cuộc trò chuyện này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开此对话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟此對話" + } + } + } + }, + "chats.section.header" : { + "comment" : "Section header above recent direct conversations in the people sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الدردشات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "চ্যাট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گفتگوها" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "discussions" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "צ'אטים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चैट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "obrolan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チャット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "च्याटहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "czaty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "чаты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "chattar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அரட்டைகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชท" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sohbetler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "чати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "چیٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trò chuyện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + } + } + }, "content.system.media_delete_refused" : { "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", "extractionState" : "manual", diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..2c7a4c0a 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -365,6 +365,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Direct conversations survive channel switches; the + // geoDM someone opened from another cell must stay + // reachable here too. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } else { PeopleSectionHeader( icon: "antenna.radiowaves.left.and.right", @@ -400,6 +410,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Conversations with people no roster above lists + // anymore — without this, a read DM from an offline + // non-favorite had no row anywhere in the UI. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } } .padding(.top, 4) diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift new file mode 100644 index 00000000..85180eb4 --- /dev/null +++ b/bitchat/Views/RecentChatList.swift @@ -0,0 +1,89 @@ +// +// RecentChatList.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import SwiftUI + +/// "chats" section for the people sheet: direct conversations with people +/// who are not in any roster above it (offline passersby, geoDMs from a +/// channel since left). Before this section existed, those threads were +/// unreachable the moment the unread envelope cleared — still in memory, +/// no row anywhere in the UI. Renders nothing when there are none. +struct RecentChatList: View { + @ThemedPalette private var palette + + let chats: [RecentChatRow] + let onTapChat: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation") + } + + /// Relative "5 min ago" stamps; the formatter is locale-aware. + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + var body: some View { + if !chats.isEmpty { + VStack(alignment: .leading, spacing: 0) { + // Same glyph+label header shape as #mesh / groups. + PeopleSectionHeader( + icon: "bubble.left.and.bubble.right", + iconColor: palette.secondary, + title: Strings.header + ) + + ForEach(chats) { chat in + HStack(spacing: 4) { + Text(verbatim: chat.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + + Spacer() + + if chat.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapChat(chat.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: chat)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openChatHint) + } + } + } + } + + private func accessibilityDescription(for chat: RecentChatRow) -> String { + var parts: [String] = [ + chat.displayName, + Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()) + ] + if chat.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 2fdb94df..ccb42d21 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -458,6 +458,56 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("Recent chats list direct conversations with people absent from the rosters") + @MainActor + func peerListModelSurfacesRecentChatsForAbsentPeers() async { + let viewModel = makeArchitectureViewModel() + let offlinePeerID = PeerID(str: "00000000000000aa") + let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16)) + + // A DM thread from a passerby who has since gone offline: not in + // allPeers, not a favorite — previously unreachable once read. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "dm-1", + sender: "passerby", + content: "hello from the train", + timestamp: Date(timeIntervalSince1970: 100), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: offlinePeerID + ) + ], for: offlinePeerID) + // Group threads have their own section and must not duplicate here. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "gm-1", + sender: "member", + content: "group hello", + timestamp: Date(timeIntervalSince1970: 200), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: groupPeerID + ) + ], for: groupPeerID) + + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversations: viewModel.conversations + ) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID } + } + + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) + #expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100)) + // The roster doesn't list this peer — that's exactly why the row exists. + #expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID }) + } + @Test("PrivateConversationModel resolves canonical header state for the selected DM") @MainActor func privateConversationModelResolvesSelectedHeaderState() async {