mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Add a recent-chats section so DM conversations can't become unreachable
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 <noreply@anthropic.com>
This commit is contained in:
parent
681c180060
commit
71c2ea2a93
@ -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<String>()
|
||||
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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
89
bitchat/Views/RecentChatList.swift
Normal file
89
bitchat/Views/RecentChatList.swift
Normal file
@ -0,0 +1,89 @@
|
||||
//
|
||||
// RecentChatList.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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: ", ")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user