mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:45:19 +00:00
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:
@@ -14,8 +14,11 @@ import Foundation
|
|||||||
protocol ChatDeliveryContext: AnyObject {
|
protocol ChatDeliveryContext: AnyObject {
|
||||||
var messages: [BitchatMessage] { get set }
|
var messages: [BitchatMessage] { get set }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
var sentReadReceipts: Set<String> { get set }
|
|
||||||
var isStartupPhase: Bool { get }
|
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()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||||
@@ -57,10 +60,7 @@ final class ChatDeliveryCoordinator {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
let oldCount = context.sentReadReceipts.count
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||||
context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
|
|
||||||
|
|
||||||
let removedCount = oldCount - context.sentReadReceipts.count
|
|
||||||
if removedCount > 0 {
|
if removedCount > 0 {
|
||||||
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ protocol ChatNostrContext: AnyObject {
|
|||||||
// MARK: Channel & subscription state
|
// MARK: Channel & subscription state
|
||||||
var activeChannel: ChannelID { get set }
|
var activeChannel: ChannelID { get set }
|
||||||
var currentGeohash: String? { get set }
|
var currentGeohash: String? { get set }
|
||||||
var geoSubscriptionID: String? { get set }
|
var geoSubscriptionID: String? { get }
|
||||||
var geoDmSubscriptionID: String? { get set }
|
var geoDmSubscriptionID: String? { get }
|
||||||
|
func setGeoChatSubscriptionID(_ id: String?)
|
||||||
|
func setGeoDmSubscriptionID(_ id: String?)
|
||||||
/// Geohash sampling subscriptions: subscription ID -> geohash.
|
/// 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.
|
/// Per-geohash notification cooldown: geohash -> last notify time.
|
||||||
var lastGeoNotificationAt: [String: Date] { get set }
|
var lastGeoNotificationAt: [String: Date] { get set }
|
||||||
var nostrRelayManager: NostrRelayManager? { get }
|
var nostrRelayManager: NostrRelayManager? { get }
|
||||||
@@ -44,7 +51,9 @@ protocol ChatNostrContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Inbound private (geohash DM) payloads
|
// MARK: Inbound private (geohash DM) payloads
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
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(
|
func handlePrivateMessage(
|
||||||
_ payload: NoisePayload,
|
_ payload: NoisePayload,
|
||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
@@ -225,12 +234,12 @@ final class ChatNostrCoordinator {
|
|||||||
|
|
||||||
if let dmSub = context.geoDmSubscriptionID {
|
if let dmSub = context.geoDmSubscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||||
context.geoDmSubscriptionID = nil
|
context.setGeoDmSubscriptionID(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.geoDmSubscriptionID = dmSub
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(
|
let dmFilter = NostrFilter.giftWrapsFor(
|
||||||
pubkey: identity.publicKeyHex,
|
pubkey: identity.publicKeyHex,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
@@ -274,8 +283,8 @@ final class ChatNostrCoordinator {
|
|||||||
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||||
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||||
@@ -349,7 +358,7 @@ final class ChatNostrCoordinator {
|
|||||||
|
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
let convKey = PeerID(nostr_: senderPubkey)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.nostrKeyMapping[convKey] = senderPubkey
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
switch noisePayload.type {
|
switch noisePayload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
@@ -400,11 +409,11 @@ final class ChatNostrCoordinator {
|
|||||||
|
|
||||||
if let sub = context.geoSubscriptionID {
|
if let sub = context.geoSubscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||||
context.geoSubscriptionID = nil
|
context.setGeoChatSubscriptionID(nil)
|
||||||
}
|
}
|
||||||
if let dmSub = context.geoDmSubscriptionID {
|
if let dmSub = context.geoDmSubscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||||
context.geoDmSubscriptionID = nil
|
context.setGeoDmSubscriptionID(nil)
|
||||||
}
|
}
|
||||||
context.currentGeohash = nil
|
context.currentGeohash = nil
|
||||||
context.setActiveParticipantGeohash(nil)
|
context.setActiveParticipantGeohash(nil)
|
||||||
@@ -429,7 +438,7 @@ final class ChatNostrCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let subID = "geo-\(channel.geohash)"
|
let subID = "geo-\(channel.geohash)"
|
||||||
context.geoSubscriptionID = subID
|
context.setGeoChatSubscriptionID(subID)
|
||||||
context.startGeoParticipantRefreshTimer()
|
context.startGeoParticipantRefreshTimer()
|
||||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||||
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
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.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||||
|
|
||||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||||
return
|
return
|
||||||
@@ -547,7 +556,7 @@ final class ChatNostrCoordinator {
|
|||||||
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
|
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
|
||||||
|
|
||||||
let dmSub = "geo-dm-\(channel.geohash)"
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
context.geoDmSubscriptionID = dmSub
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
if TorManager.shared.isReady {
|
if TorManager.shared.isReady {
|
||||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
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)
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
context.nostrKeyMapping[convKey] = senderPubkey
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
@@ -633,7 +642,7 @@ final class ChatNostrCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
||||||
category: .session
|
category: .session
|
||||||
@@ -666,7 +675,7 @@ final class ChatNostrCoordinator {
|
|||||||
|
|
||||||
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
|
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
context.geoSamplingSubs.removeValue(forKey: subID)
|
context.removeGeoSamplingSub(subID)
|
||||||
}
|
}
|
||||||
|
|
||||||
for gh in toAdd {
|
for gh in toAdd {
|
||||||
@@ -678,7 +687,7 @@ final class ChatNostrCoordinator {
|
|||||||
func subscribe(_ gh: String) {
|
func subscribe(_ gh: String) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
let subID = "geo-sample-\(gh)"
|
let subID = "geo-sample-\(gh)"
|
||||||
context.geoSamplingSubs[subID] = gh
|
context.addGeoSamplingSub(subID, forGeohash: gh)
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
gh,
|
gh,
|
||||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||||
@@ -771,10 +780,9 @@ final class ChatNostrCoordinator {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func endGeohashSampling() {
|
func endGeohashSampling() {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
for subID in context.geoSamplingSubs.keys {
|
for subID in context.clearGeoSamplingSubs() {
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
}
|
}
|
||||||
context.geoSamplingSubs.removeAll()
|
|
||||||
clearGeoSamplingEventDedup()
|
clearGeoSamplingEventDedup()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +893,7 @@ final class ChatNostrCoordinator {
|
|||||||
let payload = NoisePayload.decode(packet.payload) {
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
context.nostrKeyMapping[targetPeerID] = senderPubkey
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
@@ -1063,7 +1071,7 @@ final class ChatNostrCoordinator {
|
|||||||
func startGeohashDM(withPubkeyHex hex: String) {
|
func startGeohashDM(withPubkeyHex hex: String) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
let convKey = PeerID(nostr_: hex)
|
let convKey = PeerID(nostr_: hex)
|
||||||
context.nostrKeyMapping[convKey] = hex
|
context.registerNostrKeyMapping(hex, for: convKey)
|
||||||
context.startPrivateChat(with: convKey)
|
context.startPrivateChat(with: convKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
.visibleGeohashPeople()
|
.visibleGeohashPeople()
|
||||||
.first(where: { $0.displayName == nickname }) {
|
.first(where: { $0.displayName == nickname }) {
|
||||||
let conversationKey = PeerID(nostr_: person.id)
|
let conversationKey = PeerID(nostr_: person.id)
|
||||||
viewModel.nostrKeyMapping[conversationKey] = person.id
|
viewModel.registerNostrKeyMapping(person.id, for: conversationKey)
|
||||||
return conversationKey
|
return conversationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
.lowercased() ?? nickname.lowercased()
|
.lowercased() ?? nickname.lowercased()
|
||||||
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
|
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
|
||||||
let conversationKey = PeerID(nostr_: pubkey)
|
let conversationKey = PeerID(nostr_: pubkey)
|
||||||
viewModel.nostrKeyMapping[conversationKey] = pubkey
|
viewModel.registerNostrKeyMapping(pubkey, for: conversationKey)
|
||||||
return conversationKey
|
return conversationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,23 @@ import Foundation
|
|||||||
protocol ChatPrivateConversationContext: AnyObject {
|
protocol ChatPrivateConversationContext: AnyObject {
|
||||||
// MARK: Conversation state
|
// MARK: Conversation state
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
var sentReadReceipts: Set<String> { get set }
|
var sentReadReceipts: Set<String> { get }
|
||||||
var sentGeoDeliveryAcks: Set<String> { get set }
|
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
var unreadPrivateMessages: Set<PeerID> { get set }
|
||||||
var selectedPrivateChatPeer: PeerID? { get set }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var activeChannel: ChannelID { get }
|
var activeChannel: ChannelID { get }
|
||||||
var nostrKeyMapping: [PeerID: String] { 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()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
|
|
||||||
@@ -62,8 +72,10 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatPrivateConversationContext {
|
extension ChatViewModel: ChatPrivateConversationContext {
|
||||||
// `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared
|
// `privateChats` and `notifyUIChanged()` are shared requirements with
|
||||||
// requirements with `ChatDeliveryContext`; the remaining state members are
|
// `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.
|
// satisfied by existing `ChatViewModel` properties and methods.
|
||||||
|
|
||||||
var myPeerID: PeerID { meshService.myPeerID }
|
var myPeerID: PeerID { meshService.myPeerID }
|
||||||
@@ -425,15 +437,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
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.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
context.sentGeoDeliveryAcks.insert(messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
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.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
context.sentReadReceipts.insert(messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePrivateMessage(
|
func handlePrivateMessage(
|
||||||
@@ -579,7 +589,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
readerNickname: context.nickname
|
readerNickname: context.nickname
|
||||||
)
|
)
|
||||||
context.sendMeshReadReceipt(receipt, to: peerID)
|
context.sendMeshReadReceipt(receipt, to: peerID)
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
} else {
|
} else {
|
||||||
context.unreadPrivateMessages.insert(peerID)
|
context.unreadPrivateMessages.insert(peerID)
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
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)
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
} else if let identity = context.currentNostrIdentity() {
|
} else if let identity = context.currentNostrIdentity() {
|
||||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
@@ -802,17 +812,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !oldPeerIDsToRemove.isEmpty {
|
if !oldPeerIDsToRemove.isEmpty {
|
||||||
let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
|
|
||||||
|
|
||||||
for oldID in oldPeerIDsToRemove {
|
for oldID in oldPeerIDsToRemove {
|
||||||
context.privateChats.removeValue(forKey: oldID)
|
context.privateChats.removeValue(forKey: oldID)
|
||||||
context.unreadPrivateMessages.remove(oldID)
|
context.unreadPrivateMessages.remove(oldID)
|
||||||
context.clearStoredFingerprint(for: oldID)
|
context.clearStoredFingerprint(for: oldID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsSelectedUpdate {
|
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
|
||||||
context.selectedPrivateChatPeer = peerID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !migratedMessages.isEmpty {
|
if !migratedMessages.isEmpty {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
var currentGeohash: String? { get }
|
var currentGeohash: String? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var myPeerID: PeerID { 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()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
func trimMessagesIfNeeded()
|
func trimMessagesIfNeeded()
|
||||||
@@ -56,7 +59,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
// MARK: Geohash participants & presence
|
// MARK: Geohash participants & presence
|
||||||
var geoNicknames: [String: String] { get }
|
var geoNicknames: [String: String] { get }
|
||||||
var isTeleported: Bool { 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 visibleGeoPeople() -> [GeoPerson]
|
||||||
func geoParticipantCount(for geohash: String) -> Int
|
func geoParticipantCount(for geohash: String) -> Int
|
||||||
func removeGeoParticipant(pubkeyHex: String)
|
func removeGeoParticipant(pubkeyHex: String)
|
||||||
@@ -88,7 +93,7 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
extension ChatViewModel: ChatPublicConversationContext {
|
extension ChatViewModel: ChatPublicConversationContext {
|
||||||
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
||||||
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
||||||
// `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`,
|
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
|
||||||
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
||||||
// `deriveNostrIdentity(forGeohash:)`, and
|
// `deriveNostrIdentity(forGeohash:)`, and
|
||||||
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
|
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
|
||||||
@@ -247,9 +252,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
context.unreadPrivateMessages = unread
|
context.unreadPrivateMessages = unread
|
||||||
}
|
}
|
||||||
|
|
||||||
for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
|
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
||||||
context.nostrKeyMapping.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
addSystemMessage(
|
addSystemMessage(
|
||||||
String(
|
String(
|
||||||
@@ -616,7 +619,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
||||||
context.isBatchingPublic = isBatching
|
context.setPublicBatching(isBatching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,8 +292,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var geoSubscriptionID: String? = nil
|
// Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below.
|
||||||
var geoDmSubscriptionID: String? = nil
|
private(set) var geoSubscriptionID: String? = nil
|
||||||
|
private(set) var geoDmSubscriptionID: String? = nil
|
||||||
var currentGeohash: String? {
|
var currentGeohash: String? {
|
||||||
get { locationPresenceStore.currentGeohash }
|
get { locationPresenceStore.currentGeohash }
|
||||||
set { locationPresenceStore.setCurrentGeohash(newValue) }
|
set { locationPresenceStore.setCurrentGeohash(newValue) }
|
||||||
@@ -366,7 +367,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
|
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
|
||||||
} // lowercased pubkey hex
|
} // lowercased pubkey hex
|
||||||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
// 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
|
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
||||||
|
|
||||||
// MARK: - Message Delivery Tracking
|
// MARK: - Message Delivery Tracking
|
||||||
@@ -384,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// MARK: - Public message batching (UI perf)
|
// MARK: - Public message batching (UI perf)
|
||||||
let publicMessagePipeline: PublicMessagePipeline
|
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)
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
// 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)
|
// 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
|
// Track app startup phase to prevent marking old messages as unread
|
||||||
var isStartupPhase = true
|
var isStartupPhase = true
|
||||||
@@ -411,7 +415,96 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var torInitialReadyAnnounced: Bool = false
|
var torInitialReadyAnnounced: Bool = false
|
||||||
|
|
||||||
// Track Nostr pubkey mappings for unknown senders
|
// 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
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
var lastGeoNotificationAt: [String: Date] = [:]
|
var lastGeoNotificationAt: [String: Date] = [:]
|
||||||
var nostrRelayManager: NostrRelayManager? { nil }
|
var nostrRelayManager: NostrRelayManager? { nil }
|
||||||
|
|
||||||
|
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
|
||||||
|
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
|
||||||
|
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
|
||||||
|
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
|
||||||
|
|
||||||
|
func clearGeoSamplingSubs() -> [String] {
|
||||||
|
defer { geoSamplingSubs.removeAll() }
|
||||||
|
return Array(geoSamplingSubs.keys)
|
||||||
|
}
|
||||||
|
|
||||||
// Public timeline & pipeline
|
// Public timeline & pipeline
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
private(set) var pipelineResetCount = 0
|
private(set) var pipelineResetCount = 0
|
||||||
@@ -71,6 +81,7 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
// Inbound private (geohash DM) payloads
|
// Inbound private (geohash DM) payloads
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var nostrKeyMapping: [PeerID: String] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
||||||
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
||||||
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
||||||
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
var nostrKeyMapping: [PeerID: String] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
|
||||||
|
sentGeoDeliveryAcks.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
|
||||||
|
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
|
||||||
|
selectedPrivateChatPeer = newPeerID
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,14 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
var currentGeohash: String?
|
var currentGeohash: String?
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
var myPeerID = PeerID(str: "0011223344556677")
|
var myPeerID = PeerID(str: "0011223344556677")
|
||||||
var isBatchingPublic = false
|
private(set) var isBatchingPublic = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var trimMessagesCount = 0
|
private(set) var trimMessagesCount = 0
|
||||||
|
|
||||||
|
func setPublicBatching(_ isBatching: Bool) {
|
||||||
|
isBatchingPublic = isBatching
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
@@ -147,6 +151,12 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
var geoParticipantCounts: [String: Int] = [:]
|
var geoParticipantCounts: [String: Int] = [:]
|
||||||
private(set) var removedGeoParticipants: [String] = []
|
private(set) var removedGeoParticipants: [String] = []
|
||||||
|
|
||||||
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
|
||||||
|
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
|
||||||
|
nostrKeyMapping.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func visibleGeoPeople() -> [GeoPerson] {
|
func visibleGeoPeople() -> [GeoPerson] {
|
||||||
geoPeople
|
geoPeople
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,6 +388,12 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
|
|
||||||
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
|
let oldCount = sentReadReceipts.count
|
||||||
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
|
return oldCount - sentReadReceipts.count
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
||||||
|
|
||||||
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
|
viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID)
|
||||||
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
||||||
|
|
||||||
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
||||||
@@ -235,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// We need a peerID that looks like geo.
|
// We need a peerID that looks like geo.
|
||||||
|
|
||||||
let geoPeerID = PeerID(nostr_: hexPubkey)
|
let geoPeerID = PeerID(nostr_: hexPubkey)
|
||||||
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
|
viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID)
|
||||||
|
|
||||||
let geoMessage = BitchatMessage(
|
let geoMessage = BitchatMessage(
|
||||||
id: "msg-geo-blocked",
|
id: "msg-geo-blocked",
|
||||||
@@ -789,7 +789,7 @@ struct ChatViewModelGeoDMTests {
|
|||||||
let convKey = PeerID(nostr_: recipientHex)
|
let convKey = PeerID(nostr_: recipientHex)
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||||
viewModel.nostrKeyMapping[convKey] = recipientHex
|
viewModel.registerNostrKeyMapping(recipientHex, for: convKey)
|
||||||
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
||||||
|
|
||||||
viewModel.sendGeohashDM("hello", to: convKey)
|
viewModel.sendGeohashDM("hello", to: convKey)
|
||||||
@@ -829,6 +829,131 @@ struct ChatViewModelGeoDMTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Single-Writer Intent Operation Tests
|
||||||
|
|
||||||
|
/// Contracts for the owner-side intent ops that are the sole mutation paths
|
||||||
|
/// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`,
|
||||||
|
/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo
|
||||||
|
/// subscription IDs, and the selected private chat hand-off).
|
||||||
|
struct ChatViewModelIntentOperationTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markGeoDeliveryAckSent_returnsFalseOnSecondCall() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
#expect(viewModel.markGeoDeliveryAckSent("geo-ack-1"))
|
||||||
|
#expect(!viewModel.markGeoDeliveryAckSent("geo-ack-1"))
|
||||||
|
#expect(viewModel.markGeoDeliveryAckSent("geo-ack-2"))
|
||||||
|
#expect(viewModel.sentGeoDeliveryAcks == ["geo-ack-1", "geo-ack-2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func markReadReceiptSent_returnsFalseOnSecondCall() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
#expect(viewModel.markReadReceiptSent("read-1"))
|
||||||
|
#expect(!viewModel.markReadReceiptSent("read-1"))
|
||||||
|
#expect(viewModel.sentReadReceipts.contains("read-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func pruneSentReadReceipts_dropsStaleIDsAndReturnsRemovedCount() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
viewModel.sentReadReceipts = ["keep-1", "keep-2", "drop-1", "drop-2"]
|
||||||
|
|
||||||
|
let removed = viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2", "unrelated"])
|
||||||
|
|
||||||
|
#expect(removed == 2)
|
||||||
|
#expect(viewModel.sentReadReceipts == ["keep-1", "keep-2"])
|
||||||
|
// Nothing stale left: a second prune removes nothing.
|
||||||
|
#expect(viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2"]) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func registerNostrKeyMapping_isVisibleToNostrCoordinatorLookups() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
let convKey = PeerID(nostr_: hex)
|
||||||
|
|
||||||
|
// Registered through the owner intent op (as e.g. the private
|
||||||
|
// conversation flow does) and resolved through the Nostr coordinator,
|
||||||
|
// which reads the same backing dictionary via `ChatNostrContext`.
|
||||||
|
viewModel.registerNostrKeyMapping(hex, for: convKey)
|
||||||
|
|
||||||
|
#expect(viewModel.nostrKeyMapping[convKey] == hex)
|
||||||
|
#expect(viewModel.nostrCoordinator.fullNostrHex(forSenderPeerID: convKey) == hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func removeNostrKeyMappings_dropsEveryMappingForThePubkeyCaseInsensitively() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let hex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||||
|
let otherHex = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
|
||||||
|
viewModel.registerNostrKeyMapping(hex.uppercased(), for: PeerID(nostr: hex))
|
||||||
|
viewModel.registerNostrKeyMapping(hex, for: PeerID(nostr_: hex))
|
||||||
|
viewModel.registerNostrKeyMapping(otherHex, for: PeerID(nostr_: otherHex))
|
||||||
|
|
||||||
|
viewModel.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
||||||
|
|
||||||
|
#expect(viewModel.nostrKeyMapping[PeerID(nostr: hex)] == nil)
|
||||||
|
#expect(viewModel.nostrKeyMapping[PeerID(nostr_: hex)] == nil)
|
||||||
|
#expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func setPublicBatching_publishesBatchingState() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
#expect(!viewModel.isBatchingPublic)
|
||||||
|
viewModel.setPublicBatching(true)
|
||||||
|
#expect(viewModel.isBatchingPublic)
|
||||||
|
viewModel.setPublicBatching(false)
|
||||||
|
#expect(!viewModel.isBatchingPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
viewModel.setGeoChatSubscriptionID("geo-u4pruy")
|
||||||
|
viewModel.setGeoDmSubscriptionID("geo-dm-u4pruy")
|
||||||
|
#expect(viewModel.geoSubscriptionID == "geo-u4pruy")
|
||||||
|
#expect(viewModel.geoDmSubscriptionID == "geo-dm-u4pruy")
|
||||||
|
|
||||||
|
viewModel.setGeoChatSubscriptionID(nil)
|
||||||
|
viewModel.setGeoDmSubscriptionID(nil)
|
||||||
|
#expect(viewModel.geoSubscriptionID == nil)
|
||||||
|
#expect(viewModel.geoDmSubscriptionID == nil)
|
||||||
|
|
||||||
|
viewModel.addGeoSamplingSub("geo-sample-aaaa", forGeohash: "aaaa")
|
||||||
|
viewModel.addGeoSamplingSub("geo-sample-bbbb", forGeohash: "bbbb")
|
||||||
|
viewModel.removeGeoSamplingSub("geo-sample-aaaa")
|
||||||
|
#expect(viewModel.geoSamplingSubs == ["geo-sample-bbbb": "bbbb"])
|
||||||
|
|
||||||
|
let cleared = viewModel.clearGeoSamplingSubs()
|
||||||
|
#expect(cleared == ["geo-sample-bbbb"])
|
||||||
|
#expect(viewModel.geoSamplingSubs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func handOffSelectedPrivateChat_movesSelectionOnlyWhenSelectedPeerIsMigrated() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let oldPeer = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
let unrelatedPeer = PeerID(str: "cccccccccccccccc")
|
||||||
|
let newPeer = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||||
|
|
||||||
|
// Selection not among the migrated peers: untouched.
|
||||||
|
viewModel.selectedPrivateChatPeer = unrelatedPeer
|
||||||
|
viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
|
||||||
|
#expect(viewModel.selectedPrivateChatPeer == unrelatedPeer)
|
||||||
|
|
||||||
|
// Selection being migrated away: handed off to the new peer.
|
||||||
|
viewModel.selectedPrivateChatPeer = oldPeer
|
||||||
|
viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
|
||||||
|
#expect(viewModel.selectedPrivateChatPeer == newPeer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct ChatViewModelMediaTransferTests {
|
struct ChatViewModelMediaTransferTests {
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,16 @@ private final class PerfNostrContext: ChatNostrContext {
|
|||||||
var lastGeoNotificationAt: [String: Date] = [:]
|
var lastGeoNotificationAt: [String: Date] = [:]
|
||||||
var nostrRelayManager: NostrRelayManager? { nil }
|
var nostrRelayManager: NostrRelayManager? { nil }
|
||||||
|
|
||||||
|
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
|
||||||
|
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
|
||||||
|
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
|
||||||
|
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
|
||||||
|
|
||||||
|
func clearGeoSamplingSubs() -> [String] {
|
||||||
|
defer { geoSamplingSubs.removeAll() }
|
||||||
|
return Array(geoSamplingSubs.keys)
|
||||||
|
}
|
||||||
|
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
func resetPublicMessagePipeline() {}
|
func resetPublicMessagePipeline() {}
|
||||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
|
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
|
||||||
@@ -403,6 +413,7 @@ private final class PerfNostrContext: ChatNostrContext {
|
|||||||
|
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var nostrKeyMapping: [PeerID: String] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
||||||
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
|
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
|
||||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
||||||
@@ -460,6 +471,12 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
func markMessageDelivered(_ messageID: String) {}
|
||||||
|
|
||||||
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
|
let oldCount = sentReadReceipts.count
|
||||||
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
|
return oldCount - sentReadReceipts.count
|
||||||
|
}
|
||||||
|
|
||||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
||||||
/// deterministic IDs and timestamps.
|
/// deterministic IDs and timestamps.
|
||||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||||
|
|||||||
Reference in New Issue
Block a user