mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +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 }
|
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
|
@MainActor
|
||||||
final class PeerListModel: ObservableObject {
|
final class PeerListModel: ObservableObject {
|
||||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||||
|
@Published private(set) var recentChatRows: [RecentChatRow] = []
|
||||||
@Published private(set) var reachableMeshPeerCount = 0
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
@Published private(set) var connectedMeshPeerCount = 0
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
@Published private(set) var visibleGeohashPeerCount = 0
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
chatViewModel.groupStore.$groups
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
|
|
||||||
let geohashPeople = buildGeohashPeople()
|
let geohashPeople = buildGeohashPeople()
|
||||||
let groupRows = buildGroupRows()
|
let groupRows = buildGroupRows()
|
||||||
|
let recentChatRows = buildRecentChatRows(meshRows: meshRows)
|
||||||
|
|
||||||
self.meshRows = meshRows
|
self.meshRows = meshRows
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
self.geohashPeople = geohashPeople
|
self.geohashPeople = geohashPeople
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
self.groupRows = groupRows
|
self.groupRows = groupRows
|
||||||
|
self.recentChatRows = recentChatRows
|
||||||
renderID = (
|
renderID = (
|
||||||
meshRows.map {
|
meshRows.map {
|
||||||
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
@ -255,10 +283,82 @@ final class PeerListModel: ObservableObject {
|
|||||||
} +
|
} +
|
||||||
groupRows.map {
|
groupRows.map {
|
||||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||||
|
} +
|
||||||
|
recentChatRows.map {
|
||||||
|
"chat:\($0.id)-\($0.displayName)-\($0.hasUnread)"
|
||||||
}
|
}
|
||||||
).joined(separator: "|")
|
).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] {
|
private func buildGroupRows() -> [GroupChatRow] {
|
||||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||||
return chatViewModel.groupStore.groups.map { group in
|
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" : {
|
"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",
|
"comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
|
|||||||
@ -365,6 +365,16 @@ private struct ContentPeopleListView: View {
|
|||||||
showSidebar = true
|
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 {
|
} else {
|
||||||
PeopleSectionHeader(
|
PeopleSectionHeader(
|
||||||
icon: "antenna.radiowaves.left.and.right",
|
icon: "antenna.radiowaves.left.and.right",
|
||||||
@ -400,6 +410,16 @@ private struct ContentPeopleListView: View {
|
|||||||
showSidebar = true
|
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)
|
.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)
|
#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")
|
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
|
||||||
@MainActor
|
@MainActor
|
||||||
func privateConversationModelResolvesSelectedHeaderState() async {
|
func privateConversationModelResolvesSelectedHeaderState() async {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user