Convert shared coordinator state to owner-side intent operations

nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe,
isBatchingPublic, geo subscription lifecycle, and private-chat selection
hand-off now mutate through single intent operations on ChatViewModel,
with backing storage locked down via private(set) so the single-writer
property is compiler-enforced. Context protocols downgrade to read-only
access where reads remain. 8 new contract tests.

Known remaining writers outside the protocols: sentReadReceipts is
passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and
un-marked by ChatTransportEventCoordinator on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 21:26:21 +02:00
co-authored by Claude Fable 5
parent 80bed1f395
commit 707b22878d
12 changed files with 358 additions and 64 deletions
@@ -14,8 +14,11 @@ import Foundation
protocol ChatDeliveryContext: AnyObject {
var messages: [BitchatMessage] { get set }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var sentReadReceipts: Set<String> { get set }
var isStartupPhase: Bool { get }
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
/// Confirms receipt so the message router stops retaining the message for resend.
@@ -57,10 +60,7 @@ final class ChatDeliveryCoordinator {
}
)
let oldCount = context.sentReadReceipts.count
context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - context.sentReadReceipts.count
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
+31 -23
View File
@@ -18,10 +18,17 @@ protocol ChatNostrContext: AnyObject {
// MARK: Channel & subscription state
var activeChannel: ChannelID { get set }
var currentGeohash: String? { get set }
var geoSubscriptionID: String? { get set }
var geoDmSubscriptionID: String? { get set }
var geoSubscriptionID: String? { get }
var geoDmSubscriptionID: String? { get }
func setGeoChatSubscriptionID(_ id: String?)
func setGeoDmSubscriptionID(_ id: String?)
/// Geohash sampling subscriptions: subscription ID -> geohash.
var geoSamplingSubs: [String: String] { get set }
var geoSamplingSubs: [String: String] { get }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String)
func removeGeoSamplingSub(_ subID: String)
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
func clearGeoSamplingSubs() -> [String]
/// Per-geohash notification cooldown: geohash -> last notify time.
var lastGeoNotificationAt: [String: Date] { get set }
var nostrRelayManager: NostrRelayManager? { get }
@@ -44,7 +51,9 @@ protocol ChatNostrContext: AnyObject {
// MARK: Inbound private (geohash DM) payloads
var selectedPrivateChatPeer: PeerID? { get }
var nostrKeyMapping: [PeerID: String] { get set }
var nostrKeyMapping: [PeerID: String] { get }
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
@@ -225,12 +234,12 @@ final class ChatNostrCoordinator {
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.geoDmSubscriptionID = nil
context.setGeoDmSubscriptionID(nil)
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.geoDmSubscriptionID = dmSub
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
@@ -274,8 +283,8 @@ final class ChatNostrCoordinator {
context.setGeoNickname(nick, forPubkey: event.pubkey)
}
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
context.recordGeoParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
@@ -349,7 +358,7 @@ final class ChatNostrCoordinator {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.nostrKeyMapping[convKey] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
@@ -400,11 +409,11 @@ final class ChatNostrCoordinator {
if let sub = context.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
context.geoSubscriptionID = nil
context.setGeoChatSubscriptionID(nil)
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.geoDmSubscriptionID = nil
context.setGeoDmSubscriptionID(nil)
}
context.currentGeohash = nil
context.setActiveParticipantGeohash(nil)
@@ -429,7 +438,7 @@ final class ChatNostrCoordinator {
}
let subID = "geo-\(channel.geohash)"
context.geoSubscriptionID = subID
context.setGeoChatSubscriptionID(subID)
context.startGeoParticipantRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
@@ -504,8 +513,8 @@ final class ChatNostrCoordinator {
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
}
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
@@ -547,7 +556,7 @@ final class ChatNostrCoordinator {
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
context.geoDmSubscriptionID = dmSub
context.setGeoDmSubscriptionID(dmSub)
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
@@ -593,7 +602,7 @@ final class ChatNostrCoordinator {
}
let convKey = PeerID(nostr_: senderPubkey)
context.nostrKeyMapping[convKey] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
@@ -633,7 +642,7 @@ final class ChatNostrCoordinator {
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug(
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
category: .session
@@ -666,7 +675,7 @@ final class ChatNostrCoordinator {
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
context.geoSamplingSubs.removeValue(forKey: subID)
context.removeGeoSamplingSub(subID)
}
for gh in toAdd {
@@ -678,7 +687,7 @@ final class ChatNostrCoordinator {
func subscribe(_ gh: String) {
guard let context else { return }
let subID = "geo-sample-\(gh)"
context.geoSamplingSubs[subID] = gh
context.addGeoSamplingSub(subID, forGeohash: gh)
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
@@ -771,10 +780,9 @@ final class ChatNostrCoordinator {
@MainActor
func endGeohashSampling() {
guard let context else { return }
for subID in context.geoSamplingSubs.keys {
for subID in context.clearGeoSamplingSubs() {
NostrRelayManager.shared.unsubscribe(id: subID)
}
context.geoSamplingSubs.removeAll()
clearGeoSamplingEventDedup()
}
@@ -885,7 +893,7 @@ final class ChatNostrCoordinator {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
context.nostrKeyMapping[targetPeerID] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
@@ -1063,7 +1071,7 @@ final class ChatNostrCoordinator {
func startGeohashDM(withPubkeyHex hex: String) {
guard let context else { return }
let convKey = PeerID(nostr_: hex)
context.nostrKeyMapping[convKey] = hex
context.registerNostrKeyMapping(hex, for: convKey)
context.startPrivateChat(with: convKey)
}
@@ -301,7 +301,7 @@ final class ChatPeerIdentityCoordinator {
.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
viewModel.nostrKeyMapping[conversationKey] = person.id
viewModel.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
@@ -312,7 +312,7 @@ final class ChatPeerIdentityCoordinator {
.lowercased() ?? nickname.lowercased()
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
viewModel.nostrKeyMapping[conversationKey] = pubkey
viewModel.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
@@ -15,13 +15,23 @@ import Foundation
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var sentReadReceipts: Set<String> { get set }
var sentGeoDeliveryAcks: Set<String> { get set }
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away.
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
@@ -62,8 +72,10 @@ protocol ChatPrivateConversationContext: AnyObject {
}
extension ChatViewModel: ChatPrivateConversationContext {
// `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared
// requirements with `ChatDeliveryContext`; the remaining state members are
// `privateChats` and `notifyUIChanged()` are shared requirements with
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
// backing state in `ChatViewModel`. The remaining state members are
// satisfied by existing `ChatViewModel` properties and methods.
var myPeerID: PeerID { meshService.myPeerID }
@@ -425,15 +437,13 @@ final class ChatPrivateConversationCoordinator {
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !context.sentGeoDeliveryAcks.contains(messageId) else { return }
guard context.markGeoDeliveryAckSent(messageId) else { return }
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
context.sentGeoDeliveryAcks.insert(messageId)
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !context.sentReadReceipts.contains(messageId) else { return }
guard context.markReadReceiptSent(messageId) else { return }
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
context.sentReadReceipts.insert(messageId)
}
func handlePrivateMessage(
@@ -579,7 +589,7 @@ final class ChatPrivateConversationCoordinator {
readerNickname: context.nickname
)
context.sendMeshReadReceipt(receipt, to: peerID)
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
} else {
context.unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification(
@@ -654,10 +664,10 @@ final class ChatPrivateConversationCoordinator {
)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
@@ -802,17 +812,13 @@ final class ChatPrivateConversationCoordinator {
}
if !oldPeerIDsToRemove.isEmpty {
let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove {
context.privateChats.removeValue(forKey: oldID)
context.unreadPrivateMessages.remove(oldID)
context.clearStoredFingerprint(for: oldID)
}
if needsSelectedUpdate {
context.selectedPrivateChatPeer = peerID
}
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
}
if !migratedMessages.isEmpty {
@@ -25,7 +25,10 @@ protocol ChatPublicConversationContext: AnyObject {
var currentGeohash: String? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var isBatchingPublic: Bool { get set }
/// Publishes the public-timeline batching state (UI animation suppression).
/// (Single mutation path for the owner's `isBatchingPublic`; this
/// coordinator never reads it.)
func setPublicBatching(_ isBatching: Bool)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func trimMessagesIfNeeded()
@@ -56,7 +59,9 @@ protocol ChatPublicConversationContext: AnyObject {
// MARK: Geohash participants & presence
var geoNicknames: [String: String] { get }
var isTeleported: Bool { get }
var nostrKeyMapping: [PeerID: String] { get set }
var nostrKeyMapping: [PeerID: String] { get }
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String)
func visibleGeoPeople() -> [GeoPerson]
func geoParticipantCount(for geohash: String) -> Int
func removeGeoParticipant(pubkeyHex: String)
@@ -88,7 +93,7 @@ protocol ChatPublicConversationContext: AnyObject {
extension ChatViewModel: ChatPublicConversationContext {
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
// `deriveNostrIdentity(forGeohash:)`, and
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
@@ -247,9 +252,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.unreadPrivateMessages = unread
}
for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
context.nostrKeyMapping.removeValue(forKey: key)
}
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
addSystemMessage(
String(
@@ -616,7 +619,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
context.isBatchingPublic = isBatching
context.setPublicBatching(isBatching)
}
}
+99 -6
View File
@@ -292,8 +292,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
objectWillChange.send()
}
}
var geoSubscriptionID: String? = nil
var geoDmSubscriptionID: String? = nil
// Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below.
private(set) var geoSubscriptionID: String? = nil
private(set) var geoDmSubscriptionID: String? = nil
var currentGeohash: String? {
get { locationPresenceStore.currentGeohash }
set { locationPresenceStore.setCurrentGeohash(newValue) }
@@ -366,7 +367,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
} // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
// Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below.
private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
@@ -384,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Public message batching (UI perf)
let publicMessagePipeline: PublicMessagePipeline
@Published var isBatchingPublic: Bool = false
// Single-writer: mutate only via `setPublicBatching(_:)` below.
@Published private(set) var isBatchingPublic: Bool = false
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -403,7 +406,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
var sentGeoDeliveryAcks: Set<String> = []
// Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below.
private(set) var sentGeoDeliveryAcks: Set<String> = []
// Track app startup phase to prevent marking old messages as unread
var isStartupPhase = true
@@ -411,7 +415,96 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var torInitialReadyAnnounced: Bool = false
// Track Nostr pubkey mappings for unknown senders
var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below.
private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// MARK: - Single-Writer Intent Operations
// Owner-side mutation paths for state the coordinator contexts may read
// but not write directly. Each op is the sole way to mutate its backing
// state, so check-then-mutate races between coordinators cannot occur.
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
@MainActor
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
nostrKeyMapping[peerID] = pubkey
}
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
@MainActor
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
nostrKeyMapping.removeValue(forKey: key)
}
}
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
sentGeoDeliveryAcks.insert(messageID).inserted
}
/// Drops every recorded read receipt whose message ID is no longer valid.
/// Returns the number of receipts removed.
@MainActor
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
/// Publishes the public-timeline batching state (UI animation suppression).
@MainActor
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
}
@MainActor
func setGeoChatSubscriptionID(_ id: String?) {
geoSubscriptionID = id
}
@MainActor
func setGeoDmSubscriptionID(_ id: String?) {
geoDmSubscriptionID = id
}
@MainActor
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) {
geoSamplingSubs[subID] = geohash
}
@MainActor
func removeGeoSamplingSub(_ subID: String) {
geoSamplingSubs.removeValue(forKey: subID)
}
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
@MainActor
func clearGeoSamplingSubs() -> [String] {
let subIDs = Array(geoSamplingSubs.keys)
geoSamplingSubs.removeAll()
return subIDs
}
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away (side-effectful: re-targets the
/// private chat session and resyncs the conversation stores).
@MainActor
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
selectedPrivateChatPeer = newPeerID
}
// MARK: - Initialization