Performance & architecture: 640x hot-path win, zero coordinator back-refs, measured baselines (#1332)

Performance & architecture: 640x hot-path win, zero coordinator back-refs, measured baselines
This commit is contained in:
jack
2026-06-11 09:25:47 +01:00
committed by GitHub
40 changed files with 6361 additions and 1545 deletions
+17 -1
View File
@@ -39,7 +39,23 @@ jobs:
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }}
# Informational only: surfaces per-file and total line coverage in the
# job log so coverage trends are visible on every PR. No thresholds —
# this must never be the reason a build goes red.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
else
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
+6 -1
View File
@@ -152,6 +152,7 @@ final class NostrRelayManager: ObservableObject {
private var recentInboundEventKeyOrder: [InboundEventKey] = []
private var duplicateInboundEventDropCount = 0
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
private var inboundEventLogCount = 0
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:]
@@ -834,7 +835,11 @@ final class NostrRelayManager: ObservableObject {
return
}
if event.kind != 1059 {
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
+2
View File
@@ -47,6 +47,8 @@ enum TransportConfig {
static let nostrInboundEventDedupCap: Int = 4096
static let nostrInboundEventDedupTrimTarget: Int = 3072
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
@@ -1,44 +1,105 @@
import BitFoundation
import Foundation
/// The narrow surface `ChatComposerCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatComposerContext: AnyObject {
// MARK: Autocomplete UI state
var autocompleteSuggestions: [String] { get set }
var autocompleteRange: NSRange? { get set }
var showAutocomplete: Bool { get set }
var selectedAutocompleteIndex: Int { get set }
/// Computes mention suggestions for the text up to the cursor.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?)
/// Replaces the matched range in `text` with the chosen suggestion.
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition)
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
autocompleteService.applySuggestion(suggestion, to: text, range: range)
}
var meshNickname: String {
meshService.myNickname
}
}
@MainActor
final class ChatComposerCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatComposerContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatComposerContext) {
self.context = context
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
let peerCandidates = autocompleteCandidates()
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
let (suggestions, range) = context.autocompleteQuery(
for: text,
peers: peerCandidates,
cursorPosition: cursorPosition
)
if !suggestions.isEmpty {
viewModel.autocompleteSuggestions = suggestions
viewModel.autocompleteRange = range
viewModel.showAutocomplete = true
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = suggestions
context.autocompleteRange = range
context.showAutocomplete = true
context.selectedAutocompleteIndex = 0
} else {
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.showAutocomplete = false
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.showAutocomplete = false
context.selectedAutocompleteIndex = 0
}
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
guard let range = viewModel.autocompleteRange else { return text.count }
guard let range = context.autocompleteRange else { return text.count }
text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range)
text = context.applyAutocompleteSuggestion(nickname, to: text, range: range)
viewModel.showAutocomplete = false
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.selectedAutocompleteIndex = 0
context.showAutocomplete = false
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.selectedAutocompleteIndex = 0
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
}
@@ -52,10 +113,10 @@ final class ChatComposerCoordinator {
range: NSRange(location: 0, length: nsContent.length)
)
let peerNicknames = viewModel.meshService.getPeerNicknames()
let peerNicknames = context.meshPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(viewModel.nickname)
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
validTokens.insert(context.nickname)
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
var mentions: [String] = []
for match in matches {
@@ -72,18 +133,18 @@ final class ChatComposerCoordinator {
private extension ChatComposerCoordinator {
func autocompleteCandidates() -> [String] {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
let values = viewModel.meshService.getPeerNicknames().values
return Array(values.filter { $0 != viewModel.meshService.myNickname })
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in viewModel.geoNicknames {
for (pubkey, nick) in context.geoNicknames {
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
return Array(tokens)
@@ -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)
}
+171 -66
View File
@@ -2,36 +2,143 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatLifecycleCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
final class ChatLifecycleCoordinator {
private unowned let viewModel: ChatViewModel
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get }
var sentReadReceipts: Set<String> { get }
var nickname: String { get }
var myPeerID: PeerID { 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
/// The owner-level read pass (chat manager + receipts); used for the
/// delayed re-run after the app becomes active.
func markPrivateMessagesAsRead(from peerID: PeerID)
/// Marks the chat read in the private chat manager (sends pending mesh READ acks).
func markChatAsRead(from peerID: PeerID)
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func synchronizePrivateConversationStore()
func addSystemMessage(_ content: String)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Peers & sessions
func peerNickname(for peerID: PeerID) -> String?
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func stopMeshServices()
/// Re-reads the transport's current Bluetooth state and updates the alert UI.
func refreshBluetoothState()
// MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Nostr & geohash
var isTeleported: Bool { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func recordGeoParticipant(pubkeyHex: String)
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
// MARK: Identity persistence
/// Forces the identity manager to persist its state now.
func forceSaveIdentity()
/// Confirms the Noise identity key is still present in the keychain.
@discardableResult
func verifyIdentityKeyExists() -> Bool
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateChats`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`,
// `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
// and `favoriteRelationship(forNoiseKey:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func markChatAsRead(from peerID: PeerID) {
privateChatManager.markAsRead(from: peerID)
}
func handleDidBecomeActive() {
if let bleService = viewModel.meshService as? BLEService {
let currentState = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(currentState)
}
guard let peerID = viewModel.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let viewModel = self.viewModel
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task { @MainActor in
viewModel?.markPrivateMessagesAsRead(from: peerID)
work()
}
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(viewModel.nickname) took a screenshot *"
func stopMeshServices() {
meshService.stopServices()
}
if let peerID = viewModel.selectedPrivateChatPeer {
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
}
}
func forceSaveIdentity() {
identityManager.forceSave()
}
@discardableResult
func verifyIdentityKeyExists() -> Bool {
keychain.verifyIdentityKeyExists()
}
}
@MainActor
final class ChatLifecycleCoordinator {
private unowned let context: any ChatLifecycleContext
init(context: any ChatLifecycleContext) {
self.context = context
}
func handleDidBecomeActive() {
context.refreshBluetoothState()
guard let peerID = context.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let context = self.context
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
context?.markPrivateMessagesAsRead(from: peerID)
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(context.nickname) took a screenshot *"
if let peerID = context.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
@@ -40,9 +147,9 @@ final class ChatLifecycleCoordinator {
return
}
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
viewModel.meshService.sendMessage(
context.sendMeshMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
@@ -56,43 +163,41 @@ final class ChatLifecycleCoordinator {
)
}
viewModel.addSystemMessage("you took a screenshot")
context.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
viewModel.identityManager.forceSave()
_ = viewModel.keychain.verifyIdentityKeyExists()
context.forceSaveIdentity()
context.verifyIdentityKeyExists()
}
func applicationWillTerminate() {
viewModel.meshService.stopServices()
context.stopMeshServices()
saveIdentityState()
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
viewModel.privateChatManager.markAsRead(from: peerID)
viewModel.synchronizePrivateConversationStore()
context.markChatAsRead(from: peerID)
context.synchronizePrivateConversationStore()
if peerID.isGeoDM,
let recipientHex = viewModel.nostrKeyMapping[peerID],
case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let messages = viewModel.privateChats[peerID] ?? []
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
guard !context.sentReadReceipts.contains(message.id) else { continue }
SecureLogger.debug(
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: .session
)
let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
nostrTransport.senderPeerID = viewModel.meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(
context.sendGeohashReadReceipt(
message.id,
toRecipientHex: recipientHex,
from: identity
)
viewModel.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
}
return
}
@@ -101,16 +206,16 @@ final class ChatLifecycleCoordinator {
var peerNostrPubkey: String?
if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
} else if let peer = context.unifiedPeer(for: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) {
viewModel.unreadPrivateMessages.remove(noiseKeyHex)
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
context.unreadPrivateMessages.remove(noiseKeyHex)
}
}
@@ -121,37 +226,37 @@ final class ChatLifecycleCoordinator {
continue
}
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
guard !context.sentReadReceipts.contains(message.id) else { continue }
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
readerID: context.myPeerID,
readerNickname: context.nickname
)
let recipientPeerID = peerID.isHex
? peerID
: (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID)
viewModel.sentReadReceipts.insert(message.id)
context.routeReadReceipt(receipt, to: recipientPeerID)
context.markReadReceiptSent(message.id)
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return viewModel.messages }
guard let peerID else { return context.messages }
return getPrivateChatMessages(for: peerID)
}
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
if let ephemeralMessages = viewModel.privateChats[peerID] {
if let ephemeralMessages = context.privateChats[peerID] {
combined.append(contentsOf: ephemeralMessages)
}
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] {
if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] {
combined.append(contentsOf: stableMessages)
}
}
@@ -175,12 +280,12 @@ final class ChatLifecycleCoordinator {
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return }
guard let peerNickname = context.peerNickname(for: peerID) else { return }
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
let sessionState = context.noiseSessionState(for: peerID)
switch sessionState {
case .established:
viewModel.messageRouter.sendPrivate(
context.routePrivateMessage(
message,
to: peerID,
recipientNickname: peerNickname,
@@ -203,30 +308,30 @@ private extension ChatLifecycleCoordinator {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.meshService.peerNickname(peerID: peerID),
senderPeerID: viewModel.meshService.myPeerID
recipientNickname: context.peerNickname(for: peerID),
senderPeerID: context.myPeerID
)
var chats = viewModel.privateChats
var chats = context.privateChats
if chats[peerID] == nil {
chats[peerID] = []
}
chats[peerID]?.append(notice)
viewModel.privateChats = chats
context.privateChats = chats
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
Task { @MainActor [weak context = self.context] in
guard let context else { return }
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: viewModel.locationManager.teleported
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
@@ -236,10 +341,10 @@ private extension ChatLifecycleCoordinator {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
@@ -6,26 +6,90 @@ import Foundation
import UIKit
#endif
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
/// true dependencies explicit.
@MainActor
protocol ChatMediaTransferContext: AnyObject {
// MARK: Composition state
var canSendMediaInCurrentContext: Bool { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
func nicknameForPeer(_ peerID: PeerID) -> String
func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state
var privateChats: [PeerID: [BitchatMessage]] { get set }
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func trimMessagesIfNeeded()
func removeMessage(withID messageID: String, cleanupFile: Bool)
func addSystemMessage(_ content: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Delivery status & dedup
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String)
}
extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`, `privateChats`,
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
// `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
// and `recordContentKey(_:timestamp:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
}
}
@MainActor
final class ChatMediaTransferCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatMediaTransferContext
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatMediaTransferContext) {
self.context = context
}
func sendVoiceNote(at url: URL) {
guard viewModel.canSendMediaInCurrentContext else {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
viewModel.addSystemMessage("Voice notes are only available in mesh chats.")
context.addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
let targetPeer = context.selectedPrivateChatPeer
let message = enqueueMediaMessage(
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
targetPeer: targetPeer
@@ -41,9 +105,9 @@ final class ChatMediaTransferCoordinator {
guard let self else { return }
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
self.context.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
@@ -96,20 +160,20 @@ final class ChatMediaTransferCoordinator {
#endif
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard viewModel.canSendMediaInCurrentContext else {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
viewModel.addSystemMessage("Images are only available in mesh chats.")
context.addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
let targetPeer = context.selectedPrivateChatPeer
do {
try ImageUtils.validateImageSource(at: sourceURL)
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
viewModel.addSystemMessage("Failed to prepare image for sending.")
context.addSystemMessage("Failed to prepare image for sending.")
return
}
@@ -127,22 +191,22 @@ final class ChatMediaTransferCoordinator {
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId)
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.viewModel.addSystemMessage("Image is too large to send.")
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
}
@@ -154,22 +218,22 @@ final class ChatMediaTransferCoordinator {
if let peerID = targetPeer {
message = BitchatMessage(
sender: viewModel.nickname,
sender: context.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nicknameForPeer(peerID),
senderPeerID: viewModel.meshService.myPeerID,
recipientNickname: context.nicknameForPeer(peerID),
senderPeerID: context.myPeerID,
deliveryStatus: .sending
)
var chats = viewModel.privateChats
var chats = context.privateChats
chats[peerID, default: []].append(message)
viewModel.privateChats = chats
viewModel.trimMessagesIfNeeded()
context.privateChats = chats
context.trimMessagesIfNeeded()
} else {
let (displayName, senderPeerID) = viewModel.currentPublicSender()
let (displayName, senderPeerID) = context.currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
@@ -181,14 +245,14 @@ final class ChatMediaTransferCoordinator {
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
viewModel.trimMessagesIfNeeded()
context.appendTimelineMessage(message, to: context.activeChannel)
context.refreshVisibleMessages(from: context.activeChannel)
context.trimMessagesIfNeeded()
}
let key = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
viewModel.objectWillChange.send()
let key = context.normalizedContentKey(message.content)
context.recordContentKey(key, timestamp: timestamp)
context.notifyUIChanged()
return message
}
@@ -217,7 +281,7 @@ final class ChatMediaTransferCoordinator {
}
func handleMediaSendFailure(messageID: String, reason: String) {
viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
@@ -225,18 +289,18 @@ final class ChatMediaTransferCoordinator {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .sent)
context.updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
@@ -270,15 +334,15 @@ final class ChatMediaTransferCoordinator {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
viewModel.meshService.cancelTransfer(transferId)
context.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
File diff suppressed because it is too large Load Diff
@@ -2,34 +2,96 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatOutgoingCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatOutgoingContext: AnyObject {
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var isTeleported: Bool { get }
// MARK: Commands & private messages
func handleCommand(_ command: String)
func updatePrivateChatPeerIfNeeded()
func sendPrivateMessage(_ content: String, to peerID: PeerID)
// MARK: Public timeline (local echo)
func parseMentions(from content: String) -> [String]
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func trimMessagesIfNeeded()
func addSystemMessage(_ content: String)
// MARK: Content dedup
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Outbound routing
/// Stamps "now" as the channel's last public activity (background nudges).
/// (Single mutation path for the owner's `lastPublicActivityAt`; this
/// coordinator never reads it.)
func recordPublicActivity(forChannelKey key: String)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatOutgoingContext {
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
// `trimMessagesIfNeeded()`, `addSystemMessage(_:)`,
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op below lives next to
// its backing state's owner.
func recordPublicActivity(forChannelKey key: String) {
lastPublicActivityAt[key] = Date()
}
}
@MainActor
final class ChatOutgoingCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatOutgoingContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatOutgoingContext) {
self.context = context
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
if content.hasPrefix("/") {
Task { @MainActor [weak viewModel] in
viewModel?.handleCommand(content)
Task { @MainActor [weak context = self.context] in
context?.handleCommand(content)
}
return
}
if viewModel.selectedPrivateChatPeer != nil {
viewModel.updatePrivateChatPeerIfNeeded()
if context.selectedPrivateChatPeer != nil {
context.updatePrivateChatPeerIfNeeded()
if let selectedPeer = viewModel.selectedPrivateChatPeer {
viewModel.sendPrivateMessage(content, to: selectedPeer)
if let selectedPeer = context.selectedPrivateChatPeer {
context.sendPrivateMessage(content, to: selectedPeer)
}
return
}
let mentions = viewModel.parseMentions(from: content)
let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
@@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator {
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = viewModel.nickname
var localSenderPeerID = viewModel.meshService.myPeerID
var displaySender = context.nickname
var localSenderPeerID = context.myPeerID
var messageID: String?
var messageTimestamp = Date()
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
break
case .location(let channel):
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = viewModel.locationManager.teleported
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
nickname: context.nickname,
teleported: teleported
)
@@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator {
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
@@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator {
}
func appendLocalEcho(_ message: BitchatMessage) {
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
context.appendTimelineMessage(message, to: context.activeChannel)
context.refreshVisibleMessages(from: context.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
viewModel.trimMessagesIfNeeded()
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
context.trimMessagesIfNeeded()
}
func routePublicMessage(
@@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator {
messageID: String,
timestamp: Date
) {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
viewModel.lastPublicActivityAt["mesh"] = Date()
viewModel.meshService.sendMessage(
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
@@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator {
)
case .location(let channel):
viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date()
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak viewModel] in
viewModel?.sendGeohash(context: geoContext)
Task { @MainActor [weak context = self.context] in
context?.sendGeohash(context: geoContext)
}
}
}
@@ -3,24 +3,236 @@ import BitLogger
import CoreBluetooth
import Foundation
final class ChatPeerIdentityCoordinator {
private unowned let viewModel: ChatViewModel
/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true
/// dependencies explicit. Several members are flattened service accesses
/// this coordinator implements the `ChatViewModel`-level peer-identity API, so
/// its context members deliberately sit one level below those wrappers
/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)`
/// vs `getFingerprint(for:)`, ) to avoid call cycles.
@MainActor
protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatFingerprint: String? { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func addSystemMessage(_ content: String)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Private chat session lifecycle
/// Merges messages stored under alternate peer-ID representations into `peerID`'s chat.
/// Returns `true` when unread messages were discovered during consolidation.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool
/// Marks read receipts as sent for own messages already delivered/read in
/// `peerID`'s chat. (Single mutation path into the owner's
/// `sentReadReceipts`; this coordinator never touches the raw set.)
func syncReadReceiptsForSentMessages(for peerID: PeerID)
/// Re-targets the private chat session in the chat manager (no store-sync side effects).
func beginPrivateChatSession(with peerID: PeerID)
func synchronizePrivateConversationStore()
func synchronizeConversationSelectionStore()
func markPrivateMessagesAsRead(from peerID: PeerID)
// MARK: Unified peer service
var connectedPeers: Set<PeerID> { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedIsBlocked(_ peerID: PeerID) -> Bool
func unifiedToggleFavorite(_ peerID: PeerID)
func unifiedFingerprint(for peerID: PeerID) -> String?
func unifiedPeerID(forNickname nickname: String) -> PeerID?
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
// MARK: Mesh & Noise sessions
func peerNickname(for peerID: PeerID) -> String?
func meshPeerNicknames() -> [PeerID: String]
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func hasNoiseSession(with peerID: PeerID) -> Bool
/// Our own Noise identity fingerprint.
func noiseIdentityFingerprint() -> String
// MARK: Identity store (fingerprints & encryption status)
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID)
/// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`,
/// falling back to `fallback` when none was stored. Returns the migrated fingerprint.
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus?
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID)
func invalidateStoredEncryptionCache(for peerID: PeerID?)
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity?
// MARK: Favorites
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship for a short (ephemeral) peer ID, if any.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Removes a favorite from the favorites store.
func removeFavorite(noiseKey: Data)
// MARK: Geohash & Nostr
var geoNicknames: [String: String] { get }
func visibleGeohashPeople() -> [GeoPerson]
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func bridgedNostrPublicKey(for noiseKey: Data) -> String?
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool)
}
extension ChatViewModel: ChatPeerIdentityContext {
// `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`,
// `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`,
// `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`,
// `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`,
// `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`,
// `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`,
// `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`,
// and the conversation-store sync methods are shared requirements with
// the other contexts or satisfied by existing `ChatViewModel` members.
// The single-writer intent op `syncReadReceiptsForSentMessages(for:)`
// lives next to its backing state in `ChatViewModel`. The members below
// flatten nested service accesses into intent-named calls.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: sentReadReceipts
)
}
func beginPrivateChatSession(with peerID: PeerID) {
privateChatManager.startChat(with: peerID)
}
func unifiedIsBlocked(_ peerID: PeerID) -> Bool {
unifiedPeerService.isBlocked(peerID)
}
func unifiedToggleFavorite(_ peerID: PeerID) {
unifiedPeerService.toggleFavorite(peerID)
}
func unifiedFingerprint(for peerID: PeerID) -> String? {
unifiedPeerService.getFingerprint(for: peerID)
}
func unifiedPeerID(forNickname nickname: String) -> PeerID? {
unifiedPeerService.getPeerID(for: nickname)
}
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
meshService.getNoiseSessionState(for: peerID)
}
func triggerHandshake(with peerID: PeerID) {
meshService.triggerHandshake(with: peerID)
}
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
meshService.getNoiseService().hasEstablishedSession(with: peerID)
}
func hasNoiseSession(with peerID: PeerID) -> Bool {
meshService.getNoiseService().hasSession(with: peerID)
}
func noiseIdentityFingerprint() -> String {
meshService.getNoiseService().getIdentityFingerprint()
}
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
peerIdentityStore.setFingerprint(fingerprint, for: peerID)
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback)
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
peerIdentityStore.isVerified(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
peerIdentityStore.setEncryptionStatus(status, for: peerID)
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
peerIdentityStore.cachedEncryptionStatus(for: peerID)
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
peerIdentityStore.invalidateEncryptionCache(for: peerID)
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
identityManager.getSocialIdentity(for: fingerprint)
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
idBridge.getNostrPublicKey(for: noiseKey)
}
// `favoriteRelationship(forNoiseKey:)` is shared with
// `ChatPrivateConversationContext`; its witness lives in
// `ChatPrivateConversationCoordinator.swift`.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname
)
}
func removeFavorite(noiseKey: Data) {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
}
}
final class ChatPeerIdentityCoordinator {
private unowned let context: any ChatPeerIdentityContext
init(context: any ChatPeerIdentityContext) {
self.context = context
}
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = viewModel.unreadPrivateMessages
.map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
let unreadSorted = context.unreadPrivateMessages
.map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
return
}
let recent = viewModel.privateChats
let recent = context.privateChats
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
.sorted { $0.ts > $1.ts }
if let target = recent.first?.id {
@@ -30,7 +242,7 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func isPeerBlocked(_ peerID: PeerID) -> Bool {
viewModel.unifiedPeerService.isBlocked(peerID)
context.unifiedIsBlocked(peerID)
}
@MainActor
@@ -38,24 +250,24 @@ final class ChatPeerIdentityCoordinator {
var noiseKeyPeerID: PeerID?
var nostrPeerID: PeerID?
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
if let nostrHex = peer.nostrPublicKey {
nostrPeerID = PeerID(nostr_: nostrHex)
}
}
let context = ChatUnreadPeerContext(
let unreadContext = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: noiseKeyPeerID,
nostrPeerID: nostrPeerID,
nickname: viewModel.meshService.peerNickname(peerID: peerID)
nickname: context.peerNickname(for: peerID)
)
return ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: viewModel.unreadPrivateMessages,
privateChats: viewModel.privateChats
for: unreadContext,
unreadPrivateMessages: context.unreadPrivateMessages,
privateChats: context.privateChats
)
}
@@ -66,46 +278,46 @@ final class ChatPeerIdentityCoordinator {
return
}
viewModel.unifiedPeerService.toggleFavorite(peerID)
viewModel.objectWillChange.send()
context.unifiedToggleFavorite(peerID)
context.notifyUIChanged()
}
@MainActor
func isFavorite(peerID: PeerID) -> Bool {
if let noisePublicKey = peerID.noiseKey {
return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false
return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false
}
return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false
return context.unifiedPeer(for: peerID)?.isFavorite ?? false
}
@MainActor
func updatePrivateChatPeerIfNeeded() {
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
guard let chatFingerprint = context.selectedPrivateChatFingerprint,
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
return
}
if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID {
if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID {
migrateChatState(from: oldPeerID, to: currentPeerID)
viewModel.selectedPrivateChatPeer = currentPeerID
} else if viewModel.selectedPrivateChatPeer == nil {
viewModel.selectedPrivateChatPeer = currentPeerID
context.selectedPrivateChatPeer = currentPeerID
} else if context.selectedPrivateChatPeer == nil {
context.selectedPrivateChatPeer = currentPeerID
}
var unread = viewModel.unreadPrivateMessages
var unread = context.unreadPrivateMessages
unread.remove(currentPeerID)
viewModel.unreadPrivateMessages = unread
context.unreadPrivateMessages = unread
}
@MainActor
func startPrivateChat(with peerID: PeerID) {
guard peerID != viewModel.meshService.myPeerID else { return }
guard peerID != context.myPeerID else { return }
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown"
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if viewModel.unifiedPeerService.isBlocked(peerID) {
viewModel.addSystemMessage(
if context.unifiedIsBlocked(peerID) {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
@@ -118,9 +330,9 @@ final class ChatPeerIdentityCoordinator {
return
}
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
if let peer = context.unifiedPeer(for: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
viewModel.addSystemMessage(
context.addSystemMessage(
String(
format: String(
localized: "system.chat.requires_favorite",
@@ -133,16 +345,12 @@ final class ChatPeerIdentityCoordinator {
return
}
_ = viewModel.privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: viewModel.sentReadReceipts
)
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
if !peerID.isGeoDM && !peerID.isGeoChat {
switch viewModel.meshService.getNoiseSessionState(for: peerID) {
switch context.noiseSessionState(for: peerID) {
case .none, .failed:
viewModel.meshService.triggerHandshake(with: peerID)
context.triggerHandshake(with: peerID)
case .handshakeQueued, .handshaking, .established:
break
}
@@ -150,28 +358,24 @@ final class ChatPeerIdentityCoordinator {
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
viewModel.privateChatManager.syncReadReceiptsForSentMessages(
peerID: peerID,
nickname: viewModel.nickname,
externalReceipts: &viewModel.sentReadReceipts
)
context.syncReadReceiptsForSentMessages(for: peerID)
if let fingerprint = getFingerprint(for: peerID) {
viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID)
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
context.setStoredFingerprint(fingerprint, for: peerID)
context.selectedPrivateChatFingerprint = fingerprint
} else {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
context.selectedPrivateChatFingerprint = nil
}
viewModel.privateChatManager.startChat(with: peerID)
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
viewModel.markPrivateMessagesAsRead(from: peerID)
context.beginPrivateChatSession(with: peerID)
context.synchronizePrivateConversationStore()
context.synchronizeConversationSelectionStore()
context.markPrivateMessagesAsRead(from: peerID)
}
@MainActor
func endPrivateChat() {
viewModel.selectedPrivateChatPeer = nil
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
context.selectedPrivateChatPeer = nil
context.selectedPrivateChatFingerprint = nil
}
@MainActor
@@ -182,8 +386,8 @@ final class ChatPeerIdentityCoordinator {
func handleFavoriteStatusChanged(_ notification: Notification) {
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
Task { @MainActor [weak context = self.context] in
guard let context else { return }
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate,
@@ -200,28 +404,26 @@ final class ChatPeerIdentityCoordinator {
let peerID = PeerID(hexData: peerPublicKey)
let action = isFavorite ? "favorited" : "unfavorited"
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
viewModel.addSystemMessage("\(peerNickname) \(action) you")
context.addSystemMessage("\(peerNickname) \(action) you")
}
}
}
@MainActor
func updateEncryptionStatusForPeers() {
for peerID in viewModel.connectedPeers {
for peerID in context.connectedPeers {
updateEncryptionStatus(for: peerID)
}
}
@MainActor
func updateEncryptionStatus(for peerID: PeerID) {
let noiseService = viewModel.meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if noiseService.hasSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
if context.hasEstablishedNoiseSession(with: peerID) {
context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if context.hasNoiseSession(with: peerID) {
context.setEncryptionStatus(.noiseHandshaking, for: peerID)
} else {
viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID)
context.setEncryptionStatus(nil, for: peerID)
}
invalidateEncryptionCache(for: peerID)
@@ -229,12 +431,12 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) {
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
return cachedStatus
}
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
let sessionState = context.noiseSessionState(for: peerID)
let status: EncryptionStatus
switch sessionState {
@@ -248,18 +450,18 @@ final class ChatPeerIdentityCoordinator {
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
}
viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
context.setCachedEncryptionStatus(status, for: peerID)
return status
}
@MainActor
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID)
context.invalidateStoredEncryptionCache(for: peerID)
}
@MainActor
func getFingerprint(for peerID: PeerID) -> String? {
viewModel.unifiedPeerService.getFingerprint(for: peerID)
context.unifiedFingerprint(for: peerID)
}
@MainActor
@@ -270,12 +472,12 @@ final class ChatPeerIdentityCoordinator {
return peerID.id
}
if let nickname = viewModel.meshService.getPeerNicknames()[peerID] {
if let nickname = context.meshPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) {
let identity = context.socialIdentity(forFingerprint: fingerprint) {
if let petname = identity.localPetname {
return petname
}
@@ -289,19 +491,18 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getMyFingerprint() -> String {
viewModel.meshService.getNoiseService().getIdentityFingerprint()
context.noiseIdentityFingerprint()
}
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
switch viewModel.activeChannel {
switch context.activeChannel {
case .location:
if nickname.contains("#"),
let person = viewModel.publicConversationCoordinator
.visibleGeohashPeople()
let person = context.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
viewModel.nostrKeyMapping[conversationKey] = person.id
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
@@ -310,9 +511,9 @@ final class ChatPeerIdentityCoordinator {
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
viewModel.nostrKeyMapping[conversationKey] = pubkey
context.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
@@ -320,20 +521,20 @@ final class ChatPeerIdentityCoordinator {
break
}
return viewModel.unifiedPeerService.getPeerID(for: nickname)
return context.unifiedPeerID(forNickname: nickname)
}
@MainActor
func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
if let name = context.peerNickname(for: peerID) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
if let favorite = context.favoriteRelationship(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID.id),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
@@ -344,7 +545,7 @@ final class ChatPeerIdentityCoordinator {
private extension ChatPeerIdentityCoordinator {
@MainActor
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint {
for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint {
return peerID
}
return nil
@@ -352,8 +553,8 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
if let oldMessages = viewModel.privateChats[oldPeerID] {
var chats = viewModel.privateChats
if let oldMessages = context.privateChats[oldPeerID] {
var chats = context.privateChats
chats[newPeerID, default: []].append(contentsOf: oldMessages)
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
@@ -367,48 +568,48 @@ private extension ChatPeerIdentityCoordinator {
}
chats.removeValue(forKey: oldPeerID)
viewModel.privateChats = chats
context.privateChats = chats
}
var unread = viewModel.unreadPrivateMessages
var unread = context.unreadPrivateMessages
if unread.contains(oldPeerID) {
unread.remove(oldPeerID)
unread.insert(newPeerID)
viewModel.unreadPrivateMessages = unread
context.unreadPrivateMessages = unread
}
}
@MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if viewModel.selectedPrivateChatPeer == oldPeerID {
if context.selectedPrivateChatPeer == oldPeerID {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if viewModel.privateChats[oldPeerID] != nil {
} else if context.privateChats[oldPeerID] != nil {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
migrateChatState(from: oldPeerID, to: newPeerID)
if viewModel.selectedPrivateChatPeer == oldPeerID {
viewModel.selectedPrivateChatPeer = newPeerID
if context.selectedPrivateChatPeer == oldPeerID {
context.selectedPrivateChatPeer = newPeerID
}
if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping(
if let fingerprint = context.migrateFingerprintMapping(
from: oldPeerID,
to: newPeerID,
fallback: getFingerprint(for: newPeerID)
) {
if viewModel.selectedPrivateChatPeer == newPeerID {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
if context.selectedPrivateChatPeer == newPeerID {
context.selectedPrivateChatFingerprint = fingerprint
}
}
}
@MainActor
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
if let nickname = viewModel.meshService.peerNickname(peerID: peerID) {
if let nickname = context.peerNickname(for: peerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
return favorite.peerNickname
}
return "Unknown"
@@ -417,7 +618,7 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fingerprint = getFingerprint(for: peerID),
viewModel.peerIdentityStore.isVerified(fingerprint) {
context.isVerifiedFingerprint(fingerprint) {
return .noiseVerified
}
return .noiseSecured
@@ -425,36 +626,36 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID {
viewModel.unifiedPeerService.toggleFavorite(ephemeralID)
viewModel.objectWillChange.send()
if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) {
context.unifiedToggleFavorite(ephemeralID)
context.notifyUIChanged()
return
}
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname,
bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey)
bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey)
)
switch plan.persistenceAction {
case .add(let nickname, let nostrKey):
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
peerNostrPublicKey: nostrKey,
peerNickname: nickname
context.addFavorite(
noiseKey: noisePublicKey,
nostrPublicKey: nostrKey,
nickname: nickname
)
case .remove:
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
context.removeFavorite(noiseKey: noisePublicKey)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
if case .send(let isFavorite) = plan.notification {
viewModel.sendFavoriteNotificationViaNostr(
context.sendFavoriteNotificationViaNostr(
noisePublicKey: noisePublicKey,
isFavorite: isFavorite
)
@@ -2,16 +2,72 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatPeerListCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var hasTrackedPrivateChatSelection: Bool { get }
func updatePrivateChatPeerIfNeeded()
func cleanupOldReadReceipts()
// MARK: Peers & sessions
var unifiedPeers: [BitchatPeer] { get }
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
/// Number of mesh peers currently connected or reachable, from the
/// transport's live peer snapshots.
func activeMeshPeerCount() -> Int
func registerEphemeralSession(peerID: PeerID)
func updateEncryptionStatusForPeers()
// MARK: Notifications
/// Posts the "bitchatters nearby" local notification.
func notifyNetworkAvailable(peerCount: Int)
}
extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateChats`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
// `updateEncryptionStatusForPeers()` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// member below flattens the nested transport access into an intent-named
// call.
func activeMeshPeerCount() -> Int {
meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
.count
}
func notifyNetworkAvailable(peerCount: Int) {
NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount)
}
}
final class ChatPeerListCoordinator: @unchecked Sendable {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatPeerListContext
private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer?
private var networkEmptyTimer: Timer?
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatPeerListContext) {
self.context = context
}
deinit {
@@ -29,23 +85,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
private extension ChatPeerListCoordinator {
@MainActor
func handlePeerListUpdate(_ peers: [PeerID]) {
viewModel.isConnected = !peers.isEmpty
context.isConnected = !peers.isEmpty
cleanupStaleUnreadPeerIDs()
let meshPeers = peers.filter { peerID in
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
}
handleNetworkAvailability(meshPeers)
for peerID in peers {
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
context.registerEphemeralSession(peerID: peerID)
}
viewModel.updateEncryptionStatusForPeers()
context.updateEncryptionStatusForPeers()
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
if context.hasTrackedPrivateChatSelection {
context.updatePrivateChatPeerIfNeeded()
}
}
@@ -67,7 +123,7 @@ private extension ChatPeerListCoordinator {
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
recentlySeenPeers.formUnion(newPeers)
lastNetworkNotificationTime = Date()
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
context.notifyNetworkAvailable(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
@@ -79,34 +135,34 @@ private extension ChatPeerListCoordinator {
@MainActor
func cleanupStaleUnreadPeerIDs() {
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
guard !staleIDs.isEmpty else {
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
return
}
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
continue
}
idsToRemove.append(staleID)
viewModel.unreadPrivateMessages.remove(staleID)
context.unreadPrivateMessages.remove(staleID)
}
if !idsToRemove.isEmpty {
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
}
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
}
@MainActor
@@ -121,18 +177,14 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkResetTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug(
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers",
category: .session
)
}
@@ -165,18 +217,14 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkEmptyTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug(
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
"⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again",
category: .session
)
}
@@ -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()
@@ -59,11 +69,21 @@ protocol ChatPrivateConversationContext: AnyObject {
func addSystemMessage(_ content: String)
func addMeshOnlySystemMessage(_ content: String)
func sanitizeChat(for peerID: PeerID)
// MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Persists that the peer favorited/unfavorited us (favorites store write).
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
/// Posts the incoming-private-message local notification.
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
}
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 }
@@ -149,6 +169,23 @@ extension ChatViewModel: ChatPrivateConversationContext {
privateChatManager.sanitizeChat(for: peerID)
}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noiseKey,
favorited: favorited,
peerNickname: nickname,
peerNostrPublicKey: nostrPublicKey
)
}
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
}
private func makeGeohashNostrTransport() -> NostrTransport {
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
transport.senderPeerID = meshService.myPeerID
@@ -187,7 +224,7 @@ final class ChatPrivateConversationCoordinator {
guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = context.isPeerConnected(peerID)
let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
@@ -381,11 +418,7 @@ final class ChatPrivateConversationCoordinator {
}
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
}
context.notifyUIChanged()
@@ -425,15 +458,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,14 +610,10 @@ 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(
from: message.sender,
message: message.content,
peerID: peerID
)
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
}
context.notifyUIChanged()
@@ -654,10 +681,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
@@ -682,11 +709,7 @@ final class ChatPrivateConversationCoordinator {
context.unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
}
}
@@ -706,12 +729,12 @@ final class ChatPrivateConversationCoordinator {
return
}
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
context.updatePeerFavoritedUs(
noiseKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
nickname: senderNickname,
nostrPublicKey: nostrPubkey
)
if isFavorite && nostrPubkey != nil {
@@ -802,17 +825,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)
@@ -83,12 +88,16 @@ protocol ChatPublicConversationContext: AnyObject {
func recordContentKey(_ key: String, timestamp: Date)
/// Pre-renders the message so the formatting cache is warm before display.
func prewarmMessageFormatting(_ message: BitchatMessage)
// MARK: Notifications
/// Posts the you-were-mentioned local notification.
func notifyMention(from sender: String, message: String)
}
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
@@ -179,6 +188,10 @@ extension ChatViewModel: ChatPublicConversationContext {
func prewarmMessageFormatting(_ message: BitchatMessage) {
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
}
func notifyMention(from sender: String, message: String) {
NotificationService.shared.sendMentionNotification(from: sender, message: message)
}
}
@MainActor
@@ -247,9 +260,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(
@@ -545,7 +556,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if isMentioned && message.sender != context.nickname {
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
context.notifyMention(from: message.sender, message: message.content)
}
}
@@ -616,7 +627,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
context.isBatchingPublic = isBatching
context.setPublicBatching(isBatching)
}
}
@@ -2,26 +2,139 @@ import BitFoundation
import BitLogger
import Foundation
final class ChatTransportEventCoordinator {
private unowned let viewModel: ChatViewModel
/// The narrow surface `ChatTransportEventCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatTransportEventCoordinatorContextTests`) and makes its
/// true dependencies explicit.
@MainActor
protocol ChatTransportEventContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get set }
/// Forgets that read receipts were sent for `ids` so READ acks can be
/// re-sent after the peer reconnects. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func unmarkReadReceiptsSent(_ ids: [String])
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Inbound message handling
func isMessageBlocked(_ message: BitchatMessage) -> Bool
func handlePrivateMessage(_ message: BitchatMessage)
func handlePublicMessage(_ message: BitchatMessage)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
// MARK: Peer identity & sessions
func isPeerBlocked(_ peerID: PeerID) -> Bool
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func resolveNickname(for peerID: PeerID) -> String
func registerEphemeralSession(peerID: PeerID)
func removeEphemeralSession(peerID: PeerID)
/// Resolves the peer's Noise static key from the active Noise session, if any.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID)
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
// MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID)
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status
/// Applies the status to every known location of the message.
/// Returns `false` when no message with that ID was updated.
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
func deliveryStatus(for messageID: String) -> DeliveryStatus?
// MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
// `isConnected`, `nickname`, `myPeerID`, `privateChats`,
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
// the inbound message handlers, `isPeerBlocked(_:)`,
// `parseMentions(from:)`, `resolveNickname(for:)`,
// `cacheStablePeerID(_:for:)`, and `cachedStablePeerID(for:)` are shared
// requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op
// `unmarkReadReceiptsSent(_:)` lives next to its backing state in
// `ChatViewModel`. The members below flatten nested service accesses into
// intent-named calls.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? {
unifiedPeerService.getPeer(by: peerID)
}
func registerEphemeralSession(peerID: PeerID) {
identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
}
func removeEphemeralSession(peerID: PeerID) {
identityManager.removeEphemeralSession(peerID: peerID)
}
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
meshService.getNoiseService().getPeerPublicKeyData(peerID)
}
func flushRouterOutbox(for peerID: PeerID) {
messageRouter.flushOutbox(for: peerID)
}
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID)
}
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
}
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
deliveryCoordinator.deliveryStatus(for: messageID)
}
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
}
final class ChatTransportEventCoordinator {
private unowned let context: any ChatTransportEventContext
init(context: any ChatTransportEventContext) {
self.context = context
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { viewModel in
guard !viewModel.isMessageBlocked(message) else { return }
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
if message.isPrivate {
viewModel.handlePrivateMessage(message)
context.handlePrivateMessage(message)
} else {
viewModel.handlePublicMessage(message)
context.handlePublicMessage(message)
}
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@@ -32,9 +145,9 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { viewModel in
runOnMain { context in
let normalized = content.trimmed
let mentions = viewModel.parseMentions(from: normalized)
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
@@ -48,9 +161,9 @@ final class ChatTransportEventCoordinator {
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePublicMessage(message)
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@@ -60,13 +173,13 @@ final class ChatTransportEventCoordinator {
payload: Data,
timestamp: Date
) {
runOnMain { [self] viewModel in
runOnMain { [self] context in
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: viewModel
in: context
)
}
}
@@ -74,60 +187,61 @@ final class ChatTransportEventCoordinator {
func didConnectToPeer(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.isConnected = true
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
viewModel.objectWillChange.send()
runOnMain { context in
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
viewModel.cacheStablePeerID(stablePeerID, for: peerID)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
viewModel.messageRouter.flushOutbox(for: peerID)
context.flushRouterOutbox(for: peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.identityManager.removeEphemeralSession(peerID: peerID)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = viewModel.cachedStablePeerID(for: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
viewModel.cacheStablePeerID(derivedPeerID, for: peerID)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = viewModel.selectedPrivateChatPeer,
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: viewModel
in: context
)
}
if let messages = viewModel.privateChats[peerID] {
for message in messages where message.senderPeerID == peerID {
viewModel.sentReadReceipts.remove(message.id)
}
if let messages = context.privateChats[peerID] {
let receiptIDs = messages
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
}
}
}
private extension ChatTransportEventCoordinator {
func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) {
Task { @MainActor [weak viewModel = self.viewModel] in
guard let viewModel else { return }
action(viewModel)
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
action(context)
}
}
@@ -135,14 +249,14 @@ private extension ChatTransportEventCoordinator {
func migrateSelectedConversationIfNeeded(
from shortPeerID: PeerID,
to stablePeerID: PeerID,
in viewModel: ChatViewModel
in context: any ChatTransportEventContext
) {
if let messages = viewModel.privateChats[shortPeerID] {
if viewModel.privateChats[stablePeerID] == nil {
viewModel.privateChats[stablePeerID] = []
if let messages = context.privateChats[shortPeerID] {
if context.privateChats[stablePeerID] == nil {
context.privateChats[stablePeerID] = []
}
let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? [])
let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? [])
for message in messages where !existingIDs.contains(message.id) {
let migrated = BitchatMessage(
id: message.id,
@@ -153,25 +267,25 @@ private extension ChatTransportEventCoordinator {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID
? viewModel.meshService.myPeerID
senderPeerID: message.senderPeerID == context.myPeerID
? context.myPeerID
: stablePeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
viewModel.privateChats[stablePeerID]?.append(migrated)
context.privateChats[stablePeerID]?.append(migrated)
}
viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: shortPeerID)
context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
context.privateChats.removeValue(forKey: shortPeerID)
}
if viewModel.unreadPrivateMessages.contains(shortPeerID) {
viewModel.unreadPrivateMessages.remove(shortPeerID)
viewModel.unreadPrivateMessages.insert(stablePeerID)
if context.unreadPrivateMessages.contains(shortPeerID) {
context.unreadPrivateMessages.remove(shortPeerID)
context.unreadPrivateMessages.insert(stablePeerID)
}
viewModel.selectedPrivateChatPeer = stablePeerID
context.selectedPrivateChatPeer = stablePeerID
}
@MainActor
@@ -180,19 +294,19 @@ private extension ChatTransportEventCoordinator {
type: NoisePayloadType,
payload: Data,
timestamp: Date,
in viewModel: ChatViewModel
in context: any ChatTransportEventContext
) {
switch type {
case .privateMessage:
guard let packet = PrivateMessagePacket.decode(from: payload) else { return }
guard !viewModel.isPeerBlocked(peerID) else {
guard !context.isPeerBlocked(peerID) else {
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
return
}
let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let mentions = viewModel.parseMentions(from: packet.content)
let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown"
let mentions = context.parseMentions(from: packet.content)
let message = BitchatMessage(
id: packet.messageID,
sender: senderName,
@@ -201,24 +315,24 @@ private extension ChatTransportEventCoordinator {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
recipientNickname: context.nickname,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePrivateMessage(message)
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
context.handlePrivateMessage(message)
context.sendMeshDeliveryAck(for: packet.messageID, to: peerID)
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: viewModel)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus(
messageID,
status: .delivered(to: name, at: Date())
)
if !didUpdate {
if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) {
if case .read? = context.deliveryStatus(for: messageID) {
SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))", category: .session)
} else {
SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))", category: .session)
@@ -228,8 +342,8 @@ private extension ChatTransportEventCoordinator {
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: viewModel)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus(
messageID,
status: .read(by: name, at: Date())
)
@@ -239,15 +353,15 @@ private extension ChatTransportEventCoordinator {
}
case .verifyChallenge:
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
context.handleVerifyChallengePayload(from: peerID, payload: payload)
case .verifyResponse:
viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
context.handleVerifyResponsePayload(from: peerID, payload: payload)
}
}
@MainActor
func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String {
viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID)
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
}
}
@@ -3,6 +3,123 @@ import BitLogger
import Foundation
import Security
/// The narrow surface `ChatVerificationCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVerificationContext: AnyObject {
// MARK: Fingerprints & verification state
func getFingerprint(for peerID: PeerID) -> String?
/// The UI-facing verified-fingerprint set (peer identity store backed).
var verifiedFingerprints: Set<String> { get set }
/// The persisted verified-fingerprint set from the identity manager.
func persistedVerifiedFingerprints() -> Set<String>
/// Persists the verified flag in the identity manager.
func setIdentityVerified(fingerprint: String, verified: Bool)
/// Updates the UI-facing verified flag in the peer identity store.
func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState()
// MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func updateEncryptionStatus(for peerID: PeerID)
func invalidateEncryptionCache(for peerID: PeerID?)
/// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Peers
var unifiedPeers: [BitchatPeer] { get }
var unifiedFavorites: [BitchatPeer] { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedFingerprint(for peerID: PeerID) -> String?
func resolveNickname(for peerID: PeerID) -> String
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID)
// MARK: Noise sessions & verification transport
/// Installs the Noise service's session callbacks (single registration point).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
/// Resolves the peer's Noise static key from the active Noise session, if any.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Our own Noise static public key.
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// MARK: Notifications (shared with `ChatNostrContext`)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
}
extension ChatViewModel: ChatVerificationContext {
// `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`,
// `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`,
// `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`,
// `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`,
// `resolveNickname(for:)`, `cachedStablePeerID(for:)`,
// `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`,
// `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func persistedVerifiedFingerprints() -> Set<String> {
identityManager.getVerifiedFingerprints()
}
func setIdentityVerified(fingerprint: String, verified: Bool) {
identityManager.setVerified(fingerprint: fingerprint, verified: verified)
}
func setStoredVerified(_ fingerprint: String, verified: Bool) {
peerIdentityStore.setVerified(fingerprint, verified: verified)
}
var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers
}
var unifiedFavorites: [BitchatPeer] {
unifiedPeerService.favorites
}
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
let noiseService = meshService.getNoiseService()
noiseService.onPeerAuthenticated = onPeerAuthenticated
noiseService.onHandshakeRequired = onHandshakeRequired
}
func noiseStaticPublicKeyData() -> Data {
meshService.getNoiseService().getStaticPublicKeyData()
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier)
}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
@@ -13,44 +130,44 @@ final class ChatVerificationCoordinator {
var sent: Bool
}
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatVerificationContext
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
private var lastMutualToastAt: [String: Date] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatVerificationContext) {
self.context = context
}
func verifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
viewModel.updateEncryptionStatus(for: peerID)
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: false)
viewModel.updateEncryptionStatus(for: peerID)
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
context.setIdentityVerified(fingerprint: fingerprint, verified: false)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: false)
context.updateEncryptionStatus(for: peerID)
}
func loadVerifiedFingerprints() {
viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints())
let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
context.verifiedFingerprints = context.persistedVerifiedFingerprints()
let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
.map { $0.prefix(8) }
.joined(separator: ", ")
SecureLogger.info("🔐 Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security)
SecureLogger.info("🔐 Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security)
let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected }
let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected }
for favorite in offlineFavorites {
let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false
let fingerprint = context.unifiedFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
SecureLogger.info(
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
@@ -58,62 +175,61 @@ final class ChatVerificationCoordinator {
)
}
viewModel.invalidateEncryptionCache()
viewModel.objectWillChange.send()
context.invalidateEncryptionCache(for: nil)
context.notifyUIChanged()
}
func setupNoiseCallbacks() {
let noiseService = viewModel.meshService.getNoiseService()
context.installNoiseSessionCallbacks(
onPeerAuthenticated: { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.context.setEncryptionStatus(.noiseSecured, for: peerID)
}
if self.viewModel.peerIdentityStore.isVerified(fingerprint) {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID)
self.context.invalidateEncryptionCache(for: peerID)
if self.context.cachedStablePeerID(for: peerID) == nil,
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.context.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.context.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
}
}
self.viewModel.invalidateEncryptionCache(for: peerID)
if self.viewModel.cachedStablePeerID(for: peerID) == nil,
let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.viewModel.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.viewModel.meshService.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
},
onHandshakeRequired: { [weak self] peerID in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.context.invalidateEncryptionCache(for: peerID)
}
}
}
noiseService.onHandshakeRequired = { [weak self] peerID in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.viewModel.invalidateEncryptionCache(for: peerID)
}
}
)
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
let targetNoise = qr.noiseKeyHex.lowercased()
guard let peer = viewModel.unifiedPeerService.peers.first(where: {
guard let peer = context.unifiedPeers.first(where: {
$0.noisePublicKey.hexEncodedString().lowercased() == targetNoise
}) else {
return false
@@ -135,13 +251,12 @@ final class ChatVerificationCoordinator {
)
pendingQRVerifications[peerID] = pending
let noise = viewModel.meshService.getNoiseService()
if noise.hasEstablishedSession(with: peerID) {
viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
if context.hasEstablishedNoiseSession(with: peerID) {
context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
} else {
viewModel.meshService.triggerHandshake(with: peerID)
context.triggerHandshake(with: peerID)
}
return true
@@ -150,9 +265,7 @@ final class ChatVerificationCoordinator {
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
let myNoiseHex = viewModel.meshService
.getNoiseService()
.getStaticPublicKeyData()
let myNoiseHex = context.noiseStaticPublicKeyData()
.hexEncodedString()
.lowercased()
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
@@ -160,22 +273,22 @@ final class ChatVerificationCoordinator {
lastVerifyNonceByPeer[peerID] = challenge.nonceA
if let fingerprint = viewModel.getFingerprint(for: peerID) {
if let fingerprint = context.getFingerprint(for: peerID) {
lastInboundVerifyChallengeAt[fingerprint] = Date()
if viewModel.peerIdentityStore.isVerified(fingerprint) {
if context.isVerifiedFingerprint(fingerprint) {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID),
bodyName: context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID),
notificationPrefix: "verify-mutual"
)
}
}
viewModel.meshService.sendVerifyResponse(
context.sendVerifyResponse(
to: peerID,
noiseKeyHex: challenge.noiseKeyHex,
nonceA: challenge.nonceA
@@ -198,17 +311,17 @@ final class ChatVerificationCoordinator {
pendingQRVerifications.removeValue(forKey: peerID)
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
let shortFingerprint = fingerprint.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification(
let peerName = context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID)
context.postLocalNotification(
title: "Verified",
body: "You verified \(peerName)",
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
@@ -225,7 +338,7 @@ final class ChatVerificationCoordinator {
)
}
viewModel.updateEncryptionStatus(for: peerID)
context.updateEncryptionStatus(for: peerID)
}
}
@@ -242,7 +355,7 @@ private extension ChatVerificationCoordinator {
guard now.timeIntervalSince(lastToast) > 60 else { return }
lastMutualToastAt[fingerprint] = now
NotificationService.shared.sendLocalNotification(
context.postLocalNotification(
title: title,
body: "You and \(bodyName) verified each other",
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
+126 -14
View File
@@ -146,19 +146,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let unifiedPeerService: UnifiedPeerService
let autocompleteService: AutocompleteService
let deduplicationService: MessageDeduplicationService // internal for test access
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self)
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self)
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self)
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self)
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self)
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self)
lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self)
lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self)
lazy var composerCoordinator = ChatComposerCoordinator(context: self)
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
// Computed properties for compatibility
@MainActor
@@ -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,115 @@ 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
}
/// Forgets that read receipts were sent for `ids` so READ acks can be
/// re-sent after the peer reconnects.
@MainActor
func unmarkReadReceiptsSent(_ ids: [String]) {
sentReadReceipts.subtract(ids)
}
/// Marks read receipts as sent for own messages already delivered/read in
/// `peerID`'s chat, syncing the chat manager's tracking with the persisted
/// set. (Wraps the manager's `inout` sync so the raw set never leaks.)
@MainActor
func syncReadReceiptsForSentMessages(for peerID: PeerID) {
privateChatManager.syncReadReceiptsForSentMessages(
peerID: peerID,
nickname: nickname,
externalReceipts: &sentReadReceipts
)
}
/// 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
@@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper {
func configureGeoChannels() {
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
locationManager: viewModel.locationManager,
onChannelSwitch: { [weak viewModel] channel in
viewModel?.switchLocationChannel(to: channel)
},
beginSampling: { [weak viewModel] geohashes in
viewModel?.beginGeohashSampling(for: geohashes)
},
endSampling: { [weak viewModel] in
viewModel?.endGeohashSampling()
}
context: viewModel
)
}
@@ -12,86 +12,86 @@ extension ChatViewModel {
@MainActor
func resubscribeCurrentGeohash() {
nostrCoordinator.resubscribeCurrentGeohash()
nostrCoordinator.subscriptions.resubscribeCurrentGeohash()
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
nostrCoordinator.subscribeNostrEvent(event)
nostrCoordinator.inbound.subscribeNostrEvent(event)
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
nostrCoordinator.switchLocationChannel(to: channel)
nostrCoordinator.subscriptions.switchLocationChannel(to: channel)
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
nostrCoordinator.handleNostrEvent(event)
nostrCoordinator.inbound.handleNostrEvent(event)
}
@MainActor
func subscribeToGeoChat(_ ch: GeohashChannel) {
nostrCoordinator.subscribeToGeoChat(ch)
nostrCoordinator.subscriptions.subscribeToGeoChat(ch)
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
}
@MainActor
func sendGeohash(context: GeoOutgoingContext) {
nostrCoordinator.sendGeohash(context: context)
nostrCoordinator.subscriptions.sendGeohash(context: context)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
nostrCoordinator.beginGeohashSampling(for: geohashes)
nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes)
}
@MainActor
func subscribe(_ gh: String) {
nostrCoordinator.subscribe(gh)
nostrCoordinator.subscriptions.subscribe(gh)
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func endGeohashSampling() {
nostrCoordinator.endGeohashSampling()
nostrCoordinator.subscriptions.endGeohashSampling()
}
@MainActor
func setupNostrMessageHandling() {
nostrCoordinator.setupNostrMessageHandling()
nostrCoordinator.subscriptions.setupNostrMessageHandling()
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
nostrCoordinator.handleNostrMessage(giftWrap)
nostrCoordinator.inbound.handleNostrMessage(giftWrap)
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
await nostrCoordinator.processNostrMessage(giftWrap)
await nostrCoordinator.inbound.processNostrMessage(giftWrap)
}
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
nostrCoordinator.findNoiseKey(for: nostrPubkey)
nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey)
}
@MainActor
+27 -14
View File
@@ -9,15 +9,32 @@ import Combine
import Foundation
import Tor
/// The narrow surface `GeoChannelCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of capturing `ChatViewModel` in
/// per-callback closures. This keeps the coordinator independently testable
/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies
/// explicit. Held `weak` the owner retains the coordinator, and every
/// callback was previously a `[weak viewModel]` capture.
@MainActor
protocol GeoChannelContext: AnyObject {
func switchLocationChannel(to channel: ChannelID)
func beginGeohashSampling(for geohashes: [String])
func endGeohashSampling()
}
// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and
// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members.
extension ChatViewModel: GeoChannelContext {}
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private weak var context: (any GeoChannelContext)?
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
@@ -27,16 +44,12 @@ final class GeoChannelCoordinator {
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
context: any GeoChannelContext
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
self.context = context
start()
}
@@ -50,7 +63,7 @@ final class GeoChannelCoordinator {
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
self.context?.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
@@ -84,7 +97,7 @@ final class GeoChannelCoordinator {
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
self.context?.switchLocationChannel(to: self.locationManager.selectedChannel)
}
updateSampling()
}
@@ -93,13 +106,13 @@ final class GeoChannelCoordinator {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
context?.endGeohashSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
context?.beginGeohashSampling(for: union)
} else {
endSampling()
context?.endGeohashSampling()
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import BitFoundation
import BitLogger
import Foundation
import SwiftUI
/// The narrow surface `GeoPresenceTracker` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol GeoPresenceContext: AnyObject {
var activeChannel: ChannelID { get }
/// Per-geohash notification cooldown: geohash -> last notify time.
var lastGeoNotificationAt: [String: Date] { get set }
var geoNicknames: [String: String] { get }
var teleportedGeoCount: Int { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func parseMentions(from content: String) -> [String]
func recordGeoParticipant(pubkeyHex: String, geohash: String)
func geoParticipantCount(for geohash: String) -> Int
func markGeoTeleported(_ pubkeyHexLowercased: String)
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
func synchronizePublicConversationStore(forGeohash geohash: String)
/// Posts the sampled-geohash-activity local notification.
func notifyGeohashActivity(geohash: String, bodyPreview: String)
}
extension ChatViewModel: GeoPresenceContext {
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
// identity/blocking members, and `synchronizePublicConversationStore`
// already have witnesses on `ChatViewModel`. The members below flatten
// nested service accesses into intent-named calls.
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
timelineStore.appendIfAbsent(message, toGeohash: geohash)
}
var teleportedGeoCount: Int {
locationPresenceStore.teleportedGeo.count
}
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
}
func geoParticipantCount(for geohash: String) -> Int {
participantTracker.participantCount(for: geohash)
}
func markGeoTeleported(_ pubkeyHexLowercased: String) {
locationPresenceStore.markTeleported(pubkeyHexLowercased)
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview)
}
}
/// Geohash presence bookkeeping that is independent of relay subscriptions:
/// teleport-tag detection and marking, the sampling-event LRU dedup, and the
/// per-geohash notification cooldown for sampled activity.
final class GeoPresenceTracker {
private weak var context: (any GeoPresenceContext)?
private var recentGeoSamplingEventIDs = Set<String>()
private var recentGeoSamplingEventIDOrder: [String] = []
init(context: any GeoPresenceContext) {
self.context = context
}
/// True when the event carries a `["t", "teleport"]` tag.
static func hasTeleportTag(_ event: NostrEvent) -> Bool {
event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
}
/// Marks a peer teleported on a follow-up main-actor hop (keeps the
/// inbound hot path free of presence-store writes).
@MainActor
func scheduleMarkPeerTeleported(_ key: String, logged: Bool) {
Task { @MainActor [weak context] in
guard let context else { return }
context.markGeoTeleported(key)
if logged {
SecureLogger.info(
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard let context else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
guard event.isValidSignature() else { return }
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
guard let content = event.content.trimmedOrNilIfEmpty else { return }
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
if let my = try? context.deriveNostrIdentity(forGeohash: gh),
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
}
guard existingCount == 0 else { return }
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
guard let context else { return }
let now = Date()
let last = context.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor [weak context] in
guard let context else { return }
context.lastGeoNotificationAt[gh] = now
let senderSuffix = String(event.pubkey.suffix(4))
let nick = context.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
context.synchronizePublicConversationStore(forGeohash: gh)
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
}
}
}
/// First-seen check for sampled geohash events with LRU eviction so the
/// dedup set stays bounded across long sampling sessions.
func shouldProcessGeoSamplingEvent(_ eventID: String) -> Bool {
guard !eventID.isEmpty else { return true }
guard recentGeoSamplingEventIDs.insert(eventID).inserted else {
return false
}
recentGeoSamplingEventIDOrder.append(eventID)
let cap = TransportConfig.geoSamplingEventLRUCap
if recentGeoSamplingEventIDOrder.count > cap {
let removeCount = recentGeoSamplingEventIDOrder.count - cap
for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) {
recentGeoSamplingEventIDs.remove(staleID)
}
recentGeoSamplingEventIDOrder.removeFirst(removeCount)
}
return true
}
func clearGeoSamplingEventDedup() {
recentGeoSamplingEventIDs.removeAll()
recentGeoSamplingEventIDOrder.removeAll()
}
}
@@ -0,0 +1,384 @@
import BitFoundation
import BitLogger
import Foundation
import Tor
/// The narrow surface `GeohashSubscriptionManager` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol GeohashSubscriptionContext: AnyObject {
// MARK: Channel & subscription state
var activeChannel: ChannelID { get set }
var currentGeohash: 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 }
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]
var nostrRelayManager: NostrRelayManager? { get }
// MARK: Public timeline & pipeline
var messages: [BitchatMessage] { get }
func resetPublicMessagePipeline()
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func addPublicSystemMessage(_ content: String)
func drainPendingGeohashSystemMessages() -> [String]
// MARK: Nostr identity & dedup
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func currentNostrIdentity() -> NostrIdentity?
func recordProcessedNostrEvent(_ eventID: String)
func clearProcessedNostrEvents()
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
// MARK: Geo participants & presence
var teleportedGeoCount: Int { get }
func startGeoParticipantRefreshTimer()
func stopGeoParticipantRefreshTimer()
func setActiveParticipantGeohash(_ geohash: String?)
func recordGeoParticipant(pubkeyHex: String)
func markGeoTeleported(_ pubkeyHexLowercased: String)
func clearGeoTeleported(_ pubkeyHexLowercased: String)
func clearTeleportedGeo()
func clearGeoNicknames()
// MARK: Location channels
var isTeleported: Bool { get }
/// True when regional channels are known and the geohash is not one of them.
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool
}
extension ChatViewModel: GeohashSubscriptionContext {
// `activeChannel`, `currentGeohash`, the subscription-ID accessors, the
// identity members, and the timeline members already have witnesses on
// `ChatViewModel`. The members below flatten nested service accesses into
// intent-named calls.
func resetPublicMessagePipeline() {
publicMessagePipeline.reset()
}
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
publicMessagePipeline.updateActiveChannel(channel)
}
func drainPendingGeohashSystemMessages() -> [String] {
timelineStore.drainPendingGeohashSystemMessages()
}
func clearProcessedNostrEvents() {
deduplicationService.clearNostrCaches()
}
func startGeoParticipantRefreshTimer() {
participantTracker.startRefreshTimer()
}
func stopGeoParticipantRefreshTimer() {
participantTracker.stopRefreshTimer()
}
func setActiveParticipantGeohash(_ geohash: String?) {
participantTracker.setActiveGeohash(geohash)
}
func clearGeoTeleported(_ pubkeyHexLowercased: String) {
locationPresenceStore.clearTeleported(pubkeyHexLowercased)
}
func clearTeleportedGeo() {
locationPresenceStore.clearTeleportedGeo()
}
func clearGeoNicknames() {
locationPresenceStore.clearGeoNicknames()
}
var isTeleported: Bool {
locationManager.teleported
}
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
let channels = locationManager.availableChannels
return !channels.isEmpty && !channels.contains { $0.geohash == geohash }
}
}
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
/// only component that talks to `NostrRelayManager`; inbound events are
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context
self.inbound = inbound
self.presence = presence
}
@MainActor
func resubscribeCurrentGeohash() {
guard let context else { return }
guard case .location(let channel) = context.activeChannel else { return }
guard let subID = context.geoSubscriptionID else {
switchLocationChannel(to: context.activeChannel)
return
}
context.startGeoParticipantRefreshTimer()
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
channel.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event)
}
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.setGeoDmSubscriptionID(nil)
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
}
}
}
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
guard let context else { return }
context.resetPublicMessagePipeline()
context.activeChannel = channel
context.updatePublicMessagePipelineChannel(channel)
context.clearProcessedNostrEvents()
switch channel {
case .mesh:
context.refreshVisibleMessages(from: .mesh)
let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
context.stopGeoParticipantRefreshTimer()
context.setActiveParticipantGeohash(nil)
context.clearTeleportedGeo()
case .location:
context.refreshVisibleMessages(from: channel)
}
if case .location = channel {
for content in context.drainPendingGeohashSystemMessages() {
context.addPublicSystemMessage(content)
}
}
if let sub = context.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
context.setGeoChatSubscriptionID(nil)
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.setGeoDmSubscriptionID(nil)
}
context.currentGeohash = nil
context.setActiveParticipantGeohash(nil)
context.clearGeoNicknames()
guard case .location(let channel) = channel else { return }
context.currentGeohash = channel.geohash
context.setActiveParticipantGeohash(channel.geohash)
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
let key = identity.publicKeyHex.lowercased()
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
context.markGeoTeleported(key)
SecureLogger.info(
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
} else {
context.clearGeoTeleported(key)
}
}
let subID = "geo-\(channel.geohash)"
context.setGeoChatSubscriptionID(subID)
context.startGeoParticipantRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event)
}
}
subscribeToGeoChat(channel)
}
@MainActor
func subscribeToGeoChat(_ channel: GeohashChannel) {
guard let context else { return }
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.inbound.handleGiftWrap(giftWrap, id: identity)
}
}
}
@MainActor
func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
guard let context else { return }
let channel = geoContext.channel
let event = geoContext.event
let identity = geoContext.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
context.recordGeoParticipant(pubkeyHex: 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
)
if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
let key = identity.publicKeyHex.lowercased()
context.markGeoTeleported(key)
SecureLogger.info(
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
}
context.recordProcessedNostrEvent(event.id)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
guard let context else { return }
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
let desired = Set(geohashes)
let current = Set(context.geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
context.removeGeoSamplingSub(subID)
}
for gh in toAdd {
subscribe(gh)
}
}
@MainActor
func subscribe(_ gh: String) {
guard let context else { return }
let subID = "geo-sample-\(gh)"
context.addGeoSamplingSub(subID, forGeohash: gh)
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.presence.subscribeNostrEvent(event, gh: gh)
}
}
}
@MainActor
func endGeohashSampling() {
guard let context else { return }
for subID in context.clearGeoSamplingSubs() {
NostrRelayManager.shared.unsubscribe(id: subID)
}
presence.clearGeoSamplingEventDedup()
}
@MainActor
func setupNostrMessageHandling() {
guard let context else { return }
guard let currentIdentity = context.currentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug(
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: .session
)
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrMessage(event)
}
}
}
}
@@ -0,0 +1,500 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `NostrInboundPipeline` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol NostrInboundPipelineContext: AnyObject {
var currentGeohash: String? { get }
// MARK: Event dedup
func hasProcessedNostrEvent(_ eventID: String) -> Bool
func recordProcessedNostrEvent(_ eventID: String)
// MARK: Nostr identity & blocking
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func currentNostrIdentity() -> NostrIdentity?
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
// MARK: Favorites bridge
/// All favorite relationships, used to bridge a Nostr pubkey back to a
/// Noise key on the inbound DM path.
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
// MARK: Presence & key mapping
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func recordGeoParticipant(pubkeyHex: String)
// MARK: Inbound public messages
func handlePublicMessage(_ message: BitchatMessage)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
// MARK: Inbound private (DM) payloads
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
)
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
}
extension ChatViewModel: NostrInboundPipelineContext {
// `currentGeohash`, the identity/blocking members, key mapping, and the
// inbound message handlers already have witnesses on `ChatViewModel`.
// The members below flatten nested service accesses into intent-named calls.
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
deduplicationService.hasProcessedNostrEvent(eventID)
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(FavoritesPersistenceService.shared.favorites.values)
}
func recordProcessedNostrEvent(_ eventID: String) {
deduplicationService.recordNostrEvent(eventID)
}
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
}
func recordGeoParticipant(pubkeyHex: String) {
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
}
}
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// dominate real relay traffic; events are recorded only AFTER verification so
/// a forged-signature copy can never poison the dedup set; gift-wrap
/// verification for the account mailbox runs off-main with an atomic
/// main-actor check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
self.presence = presence
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
// Only verified events are recorded, so a forged-signature copy can
// never poison the dedup set and suppress the genuine event.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
if let gh = context.currentGeohash,
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
context.setGeoNickname(nick, forPubkey: 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 {
return
}
if GeoPresenceTracker.hasTeleportTag(event) {
let key = event.pubkey.lowercased()
let isSelf: Bool = {
if let gh = context.currentGeohash,
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
return myIdentity.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
presence.scheduleMarkPeerTeleported(key, logged: false)
}
}
let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak context] in
guard let context else { return }
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
context.handlePublicMessage(message)
if !isBlocked {
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) before Schnorr verification
// duplicates dominate real traffic and must not pay for crypto.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event)
let isSelf: Bool = {
if let gh = context.currentGeohash,
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag, !isSelf {
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
}
context.recordGeoParticipant(pubkeyHex: event.pubkey)
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
context.setGeoNickname(nickTag[1].trimmed, forPubkey: 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
}
let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak context] in
guard let context else { return }
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
context.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
guard giftWrap.isValidSignature() else { return }
context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processNostrMessage, which then does the authoritative
// check-and-record. Recording stays after verification so a
// forged-signature copy can never poison the dedup set and suppress
// the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
let currentIdentity: NostrIdentity? = await MainActor.run {
context.currentNostrIdentity()
}
guard let currentIdentity else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
if content.hasPrefix("verify:") {
return
}
if content.hasPrefix("bitchat1:") {
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: senderPubkey)
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
/// store. Lives here because the inbound DM path needs it per message;
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
guard let context else { return nil }
let favorites = context.allFavoriteRelationships()
var npubToMatch = nostrPubkey
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning(
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
category: .session
)
}
}
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug(
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: .session
)
return nil
}
}
private extension NostrInboundPipeline {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Base64URLCoding.decode(encoded),
packetData.count <= maxBytes
else {
return nil
}
return BitchatPacket.from(packetData)
}
}
@@ -0,0 +1,157 @@
//
// ChatComposerCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` exemplar.
//
// Scope note: mention parsing uses the shared, precompiled
// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton);
// everything else flows through the mock context.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatComposerContext` proving that
/// `ChatComposerCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatComposerContext: ChatComposerContext {
// Autocomplete UI state
var autocompleteSuggestions: [String] = []
var autocompleteRange: NSRange?
var showAutocomplete = false
var selectedAutocompleteIndex = -1
var queryResult: (suggestions: [String], range: NSRange?) = ([], nil)
private(set) var queriedPeerCandidates: [[String]] = []
private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = []
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
queriedPeerCandidates.append(peers.sorted())
return queryResult
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
appliedSuggestions.append((suggestion, text, range))
guard let textRange = Range(range, in: text) else { return text }
return text.replacingCharacters(in: textRange, with: suggestion)
}
// Identity & channel state
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var meshNickname = "me"
var meshNicknamesByPeerID: [PeerID: String] = [:]
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
// Geohash identity
var geoNicknames: [String: String] = [:]
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
Self.dummyIdentity
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with
/// no `ChatViewModel`.
struct ChatComposerCoordinatorContextTests {
@Test @MainActor
func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me",
]
// Matching query: suggestions and range are published, index resets.
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
coordinator.updateAutocomplete(for: "@al", cursorPosition: 3)
#expect(context.queriedPeerCandidates == [["alice", "bob"]])
#expect(context.autocompleteSuggestions == ["@alice"])
#expect(context.autocompleteRange == NSRange(location: 0, length: 3))
#expect(context.showAutocomplete)
#expect(context.selectedAutocompleteIndex == 0)
// No match: all autocomplete state is cleared.
context.queryResult = ([], nil)
context.selectedAutocompleteIndex = 3
coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5)
#expect(context.autocompleteSuggestions.isEmpty)
#expect(context.autocompleteRange == nil)
#expect(!context.showAutocomplete)
#expect(context.selectedAutocompleteIndex == 0)
}
@Test @MainActor
func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
context.geoNicknames = [
"aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me",
]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
// Without an active range the text is untouched.
var text = "hello @al"
#expect(coordinator.completeNickname("@alice", in: &text) == text.count)
#expect(context.appliedSuggestions.isEmpty)
// With a range the suggestion is applied and state cleared.
context.autocompleteRange = NSRange(location: 6, length: 3)
context.autocompleteSuggestions = ["@alice"]
context.showAutocomplete = true
let cursor = coordinator.completeNickname("@alice", in: &text)
#expect(text == "hello @alice")
#expect(cursor == 6 + "@alice".count + 1)
#expect(!context.showAutocomplete)
#expect(context.autocompleteSuggestions.isEmpty)
#expect(context.autocompleteRange == nil)
#expect(context.selectedAutocompleteIndex == 0)
}
@Test @MainActor
func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"]
let mentions = coordinator.parseMentions(
from: "hi @alice and @me and @me#0011 but not @stranger"
)
#expect(Set(mentions) == ["alice", "me", "me#0011"])
}
}
@@ -0,0 +1,346 @@
//
// ChatLifecycleCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the geohash-screenshot branch publishes via
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
// by the full view-model tests. The GeoDM read pass, the favorites-backed
// mesh/Nostr read-receipt branch (favorites are injected through the
// context), message merging, screenshot notices, and lifecycle persistence
// flows are covered here.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatLifecycleContext` proving that
/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatLifecycleContext: ChatLifecycleContext {
// Chat & receipt state
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = []
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var ownerLevelReadPasses: [PeerID] = []
private(set) var managerReadMarks: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var systemMessages: [String] = []
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
ownerLevelReadPasses.append(peerID)
}
func markChatAsRead(from peerID: PeerID) {
managerReadMarks.append(peerID)
}
// Scheduled work runs synchronously so tests never poll wall-clock queues.
private(set) var scheduledDelays: [TimeInterval] = []
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
scheduledDelays.append(delay)
work()
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Peers & sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
private(set) var stopMeshServicesCount = 0
private(set) var refreshBluetoothStateCount = 0
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func stopMeshServices() { stopMeshServicesCount += 1 }
func refreshBluetoothState() { refreshBluetoothStateCount += 1 }
// Routing & receipts
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var meshBroadcasts: [String] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, recipientNickname))
}
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
routedReadReceipts.append((receipt.originalMessageID, peerID))
}
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
meshBroadcasts.append(content)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Nostr & geohash
var isTeleported = false
private(set) var recordedGeoParticipants: [String] = []
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
// Identity persistence
private(set) var forceSaveIdentityCount = 0
private(set) var verifyIdentityKeyExistsCount = 0
func forceSaveIdentity() { forceSaveIdentityCount += 1 }
@discardableResult
func verifyIdentityKeyExists() -> Bool {
verifyIdentityKeyExistsCount += 1
return true
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Helpers
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
@MainActor
private func makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil,
isRelay: Bool = false,
deliveryStatus: DeliveryStatus? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: isRelay,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID,
deliveryStatus: deliveryStatus
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext`
/// with no `ChatViewModel`.
struct ChatLifecycleCoordinatorContextTests {
@Test @MainActor
func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
let t1 = Date(timeIntervalSince1970: 1)
let t2 = Date(timeIntervalSince1970: 2)
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2),
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
#expect(merged.map(\.id) == ["m1", "m2"])
if case .read? = merged.first?.deliveryStatus {
} else {
Issue.record("expected the .read copy of m1 to win the merge")
}
// getMessages(for: nil) falls back to the public timeline.
context.messages = [makePrivateMessage(id: "pub")]
#expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"])
}
@Test @MainActor
func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let convKey = PeerID(nostr_: "feedface00112233")
let recipientHex = "feedface00112233"
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.nostrKeyMapping[convKey] = recipientHex
context.sentReadReceipts = ["already-acked"]
context.privateChats[convKey] = [
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
]
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.managerReadMarks == [convKey])
#expect(context.privateStoreSyncCount == 1)
// Only the peer's own un-acked, non-relay message gets a READ.
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
#expect(context.sentReadReceipts.contains("m1"))
// Second pass: nothing new to send.
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.geoReadReceipts.count == 1)
}
@Test @MainActor
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.nicknamesByPeerID[peerID] = "alice"
// No established session: local notice only, no network send.
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
#expect(context.privateChats[peerID]?.first?.sender == "system")
// Established session: the peer is notified too.
context.noiseSessionStates[peerID] = .established
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.count == 1)
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
#expect(context.privateChats[peerID]?.count == 2)
// The public-channel system message is not used for private chats.
#expect(context.systemMessages.isEmpty)
#expect(context.meshBroadcasts.isEmpty)
}
@Test @MainActor
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.handleScreenshotCaptured()
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
#expect(context.systemMessages == ["you took a screenshot"])
#expect(context.privateChats.isEmpty)
}
@Test @MainActor
func lifecycleEvents_persistIdentityAndScheduleReadPasses() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.applicationWillTerminate()
#expect(context.stopMeshServicesCount == 1)
#expect(context.forceSaveIdentityCount == 1)
#expect(context.verifyIdentityKeyExistsCount == 1)
// Becoming active with no open chat only refreshes Bluetooth state.
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 1)
#expect(context.managerReadMarks.isEmpty)
// With an open chat the read pass runs immediately (manager-level) and
// a delayed owner-level pass is scheduled.
let peerID = PeerID(nostr_: "feedface00112233")
context.selectedPrivateChatPeer = peerID
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 2)
#expect(context.managerReadMarks == [peerID])
// The mock executes scheduled work synchronously, so the delayed
// owner-level pass has already run - no wall-clock polling.
#expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds])
#expect(context.ownerLevelReadPasses == [peerID])
}
@Test @MainActor
func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1alice"
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
]
coordinator.markPrivateMessagesAsRead(from: peerID)
// Favorite with a Nostr key: READ receipts routed for non-relay
// inbound messages and recorded as sent.
#expect(context.managerReadMarks == [peerID])
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
#expect(context.sentReadReceipts.contains("in-1"))
// No favorite relationship (no Nostr key): the receipt pass is skipped.
let otherKey = Data(repeating: 0xCD, count: 32)
let otherPeer = PeerID(hexData: otherKey)
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
coordinator.markPrivateMessagesAsRead(from: otherPeer)
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
}
}
@@ -0,0 +1,206 @@
//
// ChatMediaTransferCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatMediaTransferCoordinator` against a mock
// `ChatMediaTransferContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the async media-preparation pipelines (`ImageUtils`,
// `ChatMediaPreparation`) run real file/codec work and remain covered by
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
// bookkeeping, and the blocked-context guards.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatMediaTransferContext` proving that
/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Composition state
var canSendMediaInCurrentContext = true
var selectedPrivateChatPeer: PeerID?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nicknamesByPeerID: [PeerID: String] = [:]
func nicknameForPeer(_ peerID: PeerID) -> String {
nicknamesByPeerID[peerID] ?? "user"
}
func currentPublicSender() -> (name: String, peerID: PeerID) {
(nickname, myPeerID)
}
// Message state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var meshTimeline: [BitchatMessage] = []
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var trimCount = 0
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
meshTimeline.append(message)
}
func refreshVisibleMessages(from channel: ChannelID?) {
refreshedChannels.append(channel)
}
func trimMessagesIfNeeded() { trimCount += 1 }
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessages.append((messageID, cleanupFile))
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
func notifyUIChanged() { notifyUIChangedCount += 1 }
// Delivery status & dedup
private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = []
private(set) var recordedContentKeys: [String] = []
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
deliveryStatusUpdates.append((messageID, status))
}
func normalizedContentKey(_ content: String) -> String { content.lowercased() }
func recordContentKey(_ key: String, timestamp: Date) {
recordedContentKeys.append(key)
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
privateFileSends.append((peerID, transferId))
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId)
}
func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId)
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
/// `MockChatMediaTransferContext` with no `ChatViewModel`.
struct ChatMediaTransferCoordinatorContextTests {
@Test @MainActor
func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == [message.id])
#expect(message.isPrivate)
#expect(message.recipientNickname == "alice")
#expect(message.senderPeerID == context.myPeerID)
#expect(message.deliveryStatus == .sending)
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
#expect(context.trimCount == 1)
#expect(context.notifyUIChangedCount == 1)
#expect(context.meshTimeline.isEmpty)
}
@Test @MainActor
func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
#expect(context.meshTimeline.map(\.id) == [message.id])
#expect(!message.isPrivate)
#expect(message.sender == "me")
#expect(context.refreshedChannels.count == 1)
#expect(context.privateChats.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func transferEvents_driveDeliveryStatusAndMappingCleanup() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10))
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10))
coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10))
// After completion the mapping is gone: further events are ignored.
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10))
#expect(context.deliveryStatusUpdates.count == 3)
#expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10))
#expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10))
#expect(context.deliveryStatusUpdates[2].status == .sent)
#expect(coordinator.messageIDToTransferId.isEmpty)
// A cancelled transfer removes the message (with file cleanup).
coordinator.registerTransfer(transferId: "t2", messageID: "m2")
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor
func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
// Two messages share a transfer queue; only the active head cancels
// the underlying transfer.
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
coordinator.cancelMediaSend(messageID: "m2")
#expect(context.cancelledTransfers.isEmpty)
#expect(context.removedMessages.map(\.messageID) == ["m2"])
coordinator.cancelMediaSend(messageID: "m1")
#expect(context.cancelledTransfers == ["t1"])
#expect(context.removedMessages.map(\.messageID) == ["m2", "m1"])
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
context.canSendMediaInCurrentContext = false
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a")
try Data([0x01, 0x02]).write(to: url)
coordinator.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
#expect(context.privateChats.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
@@ -2,10 +2,11 @@
// ChatNostrCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests`
// exemplars.
// Exercises the `ChatNostrCoordinator` facade and its components
// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`)
// against a mock `ChatNostrContext` proving the stack works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
import Testing
@@ -15,8 +16,9 @@ import BitFoundation
// MARK: - Mock Context
/// Lightweight stand-in for `ChatNostrContext` proving that
/// `ChatNostrCoordinator` is testable without a `ChatViewModel`.
/// Lightweight stand-in for `ChatNostrContext` (and, via protocol
/// inheritance, the component contexts) proving that the Nostr stack is
/// testable without a `ChatViewModel`.
@MainActor
private final class MockChatNostrContext: ChatNostrContext {
// Channel & subscription state
@@ -28,6 +30,16 @@ private final class MockChatNostrContext: ChatNostrContext {
var lastGeoNotificationAt: [String: Date] = [:]
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
var messages: [BitchatMessage] = []
private(set) var pipelineResetCount = 0
@@ -71,6 +83,7 @@ private final class MockChatNostrContext: ChatNostrContext {
// Inbound private (geohash DM) payloads
var selectedPrivateChatPeer: PeerID?
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 handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
@@ -202,6 +215,32 @@ private final class MockChatNostrContext: ChatNostrContext {
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = []
private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = []
private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(favoriteRelationshipsByNoiseKey.values)
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
addedFavorites.append((noiseKey, nostrPublicKey, nickname))
}
func postLocalNotification(title: String, body: String, identifier: String) {
postedLocalNotifications.append((title, body, identifier))
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
geohashActivityNotifications.append((geohash, bodyPreview))
}
}
// MARK: - Helpers
@@ -215,14 +254,34 @@ private func drainMainQueue() async {
}
}
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
/// teardown, and embedded ack flows. Flows that hit live singletons
/// (`NostrRelayManager.shared` subscriptions, `TorManager`,
/// `FavoritesPersistenceService`) remain covered by the full view-model tests.
/// teardown, embedded ack flows, and now that favorites and notifications
/// are injected through the context the favorite-notification ingest and
/// the sampled-geohash notification cooldown. Flows that hit live singletons
/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by
/// the full view-model tests.
struct ChatNostrCoordinatorContextTests {
@Test @MainActor
@@ -239,7 +298,7 @@ struct ChatNostrCoordinatorContextTests {
)
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
coordinator.handleNostrEvent(event)
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// Dedup recorded exactly once, presence and key mapping updated.
@@ -257,7 +316,7 @@ struct ChatNostrCoordinatorContextTests {
#expect(context.hapticMessageIDs == [event.id])
// A replay of the same event is dropped before any processing.
coordinator.handleNostrEvent(event)
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [event.id])
#expect(context.handledPublicMessages.count == 1)
@@ -277,7 +336,7 @@ struct ChatNostrCoordinatorContextTests {
teleported: true
)
coordinator.handleNostrEvent(event)
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// Teleport detection fires even though the empty message is dropped.
@@ -299,7 +358,7 @@ struct ChatNostrCoordinatorContextTests {
)
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
coordinator.handleNostrEvent(event)
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// The event is still recorded for dedup but nothing else happens.
@@ -326,7 +385,7 @@ struct ChatNostrCoordinatorContextTests {
senderIdentity: sender
)
coordinator.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let convKey = PeerID(nostr_: sender.publicKeyHex)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
@@ -343,11 +402,35 @@ struct ChatNostrCoordinatorContextTests {
#expect(pm.content == "psst")
// The same gift wrap is dropped on replay.
coordinator.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
// A forged-signature copy is rejected WITHOUT entering the dedup set...
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
#expect(context.recordedNostrEventIDs.isEmpty)
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
@Test @MainActor
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
let context = MockChatNostrContext()
@@ -356,7 +439,7 @@ struct ChatNostrCoordinatorContextTests {
context.currentGeohash = "u4pruyd"
context.geoNicknames = ["abcd": "alice"]
coordinator.switchLocationChannel(to: .mesh)
coordinator.subscriptions.switchLocationChannel(to: .mesh)
#expect(context.pipelineResetCount == 1)
#expect(context.activeChannel == .mesh)
@@ -440,3 +523,179 @@ struct ChatNostrCoordinatorContextTests {
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
}
}
// MARK: - GeoPresenceTracker Tests
/// Focused tests for seams the coordinator split made independently
/// testable: the sampling-event LRU dedup and the per-geohash notification
/// cooldown. The cooldown tests stop short of the live notification center by
/// pre-seeding the timeline append as a duplicate.
struct GeoPresenceTrackerTests {
@Test @MainActor
func samplingEventDedup_evictsOldestBeyondLRUCap() {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let cap = TransportConfig.geoSamplingEventLRUCap
// Empty IDs are never deduplicated.
#expect(tracker.shouldProcessGeoSamplingEvent(""))
#expect(tracker.shouldProcessGeoSamplingEvent(""))
// First sight passes; a replay is rejected.
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-0"))
// Fill one past the cap: the oldest entry is evicted and accepted
// again, while a still-resident entry stays deduplicated.
for i in 1...cap {
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)"))
}
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
// Clearing resets the dedup entirely.
tracker.clearGeoSamplingEventDedup()
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
}
@Test @MainActor
func notificationCooldown_skipsWithinWindow() async throws {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "sampled activity",
geohash: "9q8yy",
senderIdentity: sender,
nickname: "alice"
)
// Within the cooldown window nothing is appended or re-stamped.
let recent = Date()
context.lastGeoNotificationAt["9q8yy"] = recent
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
await drainMainQueue()
#expect(context.appendedGeohashMessages.isEmpty)
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
#expect(context.synchronizedGeohashes.isEmpty)
}
@Test @MainActor
func notificationCooldown_stampsGeohashOnceWindowElapses() async throws {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "sampled activity",
geohash: "9q8yy",
senderIdentity: sender,
nickname: "alice"
)
// Pre-seed the same event ID so the timeline append reports a
// duplicate and the flow never reaches the live notification center.
let placeholder = BitchatMessage(
id: event.id,
sender: "seed",
content: "seed",
timestamp: Date(),
isRelay: false
)
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
// Cooldown elapsed: the geohash is re-stamped and the append is
// attempted (and rejected as a duplicate, so no store sync either).
let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1)
context.lastGeoNotificationAt["9q8yy"] = stale
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
await drainMainQueue()
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
#expect(stamped > stale)
#expect(context.appendedGeohashMessages.count == 1)
#expect(context.synchronizedGeohashes.isEmpty)
}
@Test @MainActor
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0x42, count: 32)
// The favorites store bridges the sender's npub back to a Noise key.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: sender.npub
)
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
#expect(context.addedFavorites.first?.nickname == "alice")
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
// Unfavorite: no store write, but the removal notification still posts.
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
}
@Test @MainActor
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice"
let first = try NostrProtocol.createEphemeralGeohashEvent(
content: "hello geohash",
geohash: "u4pruyd",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first)
await drainMainQueue()
// Sampled message recorded, store synced, and notification posted.
#expect(context.appendedGeohashMessages.map(\.message.id) == [first.id])
#expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4)))
#expect(context.synchronizedGeohashes == ["u4pruyd"])
#expect(context.geohashActivityNotifications.count == 1)
#expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd")
#expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash")
#expect(context.lastGeoNotificationAt["u4pruyd"] != nil)
// A second sampled event inside the cooldown window is fully suppressed.
let second = try NostrProtocol.createEphemeralGeohashEvent(
content: "again",
geohash: "u4pruyd",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second)
await drainMainQueue()
#expect(context.geohashActivityNotifications.count == 1)
#expect(context.appendedGeohashMessages.count == 1)
// Long previews are truncated to the snippet cap with an ellipsis.
let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20)
let third = try NostrProtocol.createEphemeralGeohashEvent(
content: longContent,
geohash: "9q8yyk",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third)
await drainMainQueue()
#expect(
context.geohashActivityNotifications.last?.bodyPreview
== String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + ""
)
}
}
@@ -0,0 +1,221 @@
//
// ChatOutgoingCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatOutgoingCoordinator` against a mock `ChatOutgoingContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` exemplar.
//
// Scope note: the geohash path builds and signs a real Nostr event via
// `NostrProtocol.createEphemeralGeohashEvent` (pure crypto, no shared state);
// everything else flows through the mock context.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatOutgoingContext` proving that
/// `ChatOutgoingCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatOutgoingContext: ChatOutgoingContext {
// Identity & channel state
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var isTeleported = false
// Commands & private messages
var selectedPeerAfterUpdate: PeerID??
private(set) var handledCommands: [String] = []
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
func handleCommand(_ command: String) { handledCommands.append(command) }
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
if let selectedPeerAfterUpdate {
selectedPrivateChatPeer = selectedPeerAfterUpdate
}
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
// Public timeline (local echo)
private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = []
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var trimMessagesIfNeededCount = 0
private(set) var systemMessages: [String] = []
func parseMentions(from content: String) -> [String] {
content.contains("@bob") ? ["bob"] : []
}
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
appendedTimelineMessages.append((message, channel))
}
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
func trimMessagesIfNeeded() { trimMessagesIfNeededCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Content dedup
private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = []
func normalizedContentKey(_ content: String) -> String { "key:\(content)" }
func recordContentKey(_ key: String, timestamp: Date) {
recordedContentKeys.append((key, timestamp))
}
// Outbound routing
private(set) var recordedActivityKeys: [String] = []
private(set) var sentMeshMessages: [(content: String, mentions: [String], messageID: String, timestamp: Date)] = []
private(set) var sentGeohashContexts: [ChatViewModel.GeoOutgoingContext] = []
func recordPublicActivity(forChannelKey key: String) { recordedActivityKeys.append(key) }
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sentMeshMessages.append((content, mentions, messageID, timestamp))
}
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
sentGeohashContexts.append(context)
}
// Geohash identity
struct IdentityUnavailable: Error {}
var deriveNostrIdentityError: Error?
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
if let deriveNostrIdentityError { throw deriveNostrIdentityError }
return Self.dummyIdentity
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatOutgoingCoordinator` against `MockChatOutgoingContext` with
/// no `ChatViewModel`.
struct ChatOutgoingCoordinatorContextTests {
@Test @MainActor
func sendMessage_routesSlashCommandsAndDropsEmptyContent() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
coordinator.sendMessage(" ")
coordinator.sendMessage("/who all")
await drainMainActorTasks()
#expect(context.handledCommands == ["/who all"])
#expect(context.appendedTimelineMessages.isEmpty)
#expect(context.sentMeshMessages.isEmpty)
}
@Test @MainActor
func sendMessage_inPrivateChat_reResolvesPeerBeforeSending() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
let shortPeer = PeerID(str: "1111111111111111")
let stablePeer = PeerID(str: String(repeating: "ab", count: 32))
// The selected peer is refreshed (short stable) before sending.
context.selectedPrivateChatPeer = shortPeer
context.selectedPeerAfterUpdate = stablePeer
coordinator.sendMessage("hi there")
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
#expect(context.sentPrivateMessages.map(\.peerID) == [stablePeer])
#expect(context.sentPrivateMessages.map(\.content) == ["hi there"])
// If the refresh clears the selection, nothing is sent.
context.selectedPeerAfterUpdate = PeerID??.some(nil)
coordinator.sendMessage("dropped")
await drainMainActorTasks()
#expect(context.sentPrivateMessages.count == 1)
#expect(context.appendedTimelineMessages.isEmpty)
}
@Test @MainActor
func sendMessage_onMesh_appendsLocalEchoRecordsActivityAndSends() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
coordinator.sendMessage(" hello @bob ")
await drainMainActorTasks()
// Local echo uses the trimmed content, own nickname/peer ID, mentions.
#expect(context.appendedTimelineMessages.count == 1)
let echo = context.appendedTimelineMessages[0]
#expect(echo.message.content == "hello @bob")
#expect(echo.message.sender == "me")
#expect(echo.message.senderPeerID == context.myPeerID)
#expect(echo.message.mentions == ["bob"])
#expect(echo.channel == .mesh)
#expect(context.refreshedChannels == [.mesh])
#expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"])
#expect(context.trimMessagesIfNeededCount == 1)
// The mesh send carries the original (untrimmed) content and reuses
// the echo's message ID and timestamp; activity is stamped for "mesh".
#expect(context.recordedActivityKeys == ["mesh"])
#expect(context.sentMeshMessages.count == 1)
let sent = context.sentMeshMessages[0]
#expect(sent.content == " hello @bob ")
#expect(sent.mentions == ["bob"])
#expect(sent.messageID == echo.message.id)
#expect(sent.timestamp == echo.message.timestamp)
}
@Test @MainActor
func sendMessage_onLocationChannel_sendsGeohashEventOrFailsWithSystemMessage() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
let channel = GeohashChannel(level: .city, geohash: "u4pruydq")
context.activeChannel = .location(channel)
context.isTeleported = true
coordinator.sendMessage("hello geo")
await drainMainActorTasks()
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
// the signed event's ID; the send context targets the same channel.
#expect(context.appendedTimelineMessages.count == 1)
let echo = context.appendedTimelineMessages[0].message
#expect(echo.sender == "me#2222")
#expect(context.recordedActivityKeys == ["geo:u4pruydq"])
#expect(context.sentGeohashContexts.count == 1)
let geoContext = context.sentGeohashContexts[0]
#expect(geoContext.channel == channel)
#expect(geoContext.teleported)
#expect(geoContext.event.id == echo.id)
// Identity derivation failure: system message, no echo, no send.
context.deriveNostrIdentityError = MockChatOutgoingContext.IdentityUnavailable()
coordinator.sendMessage("doomed")
await drainMainActorTasks()
#expect(context.systemMessages.count == 1)
#expect(context.appendedTimelineMessages.count == 1)
#expect(context.sentGeohashContexts.count == 1)
}
}
@@ -0,0 +1,433 @@
//
// ChatPeerIdentityCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerIdentityCoordinator` against a mock
// `ChatPeerIdentityContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: favorites are injected through the context
// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`),
// so the favorite toggle and lookup flows are covered here alongside the
// session, migration, encryption-status, and nickname resolution flows.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerIdentityContext` proving that
/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
// Conversation state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var selectedPrivateChatFingerprint: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
private(set) var notifyUIChangedCount = 0
private(set) var systemMessages: [String] = []
func notifyUIChanged() { notifyUIChangedCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Private chat session lifecycle
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
private(set) var syncedReadReceiptPeers: [PeerID] = []
private(set) var begunChatSessions: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var selectionStoreSyncCount = 0
private(set) var markedReadPeers: [PeerID] = []
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
consolidatedPeers.append((peerID, peerNickname))
return false
}
func syncReadReceiptsForSentMessages(for peerID: PeerID) {
syncedReadReceiptPeers.append(peerID)
}
func beginPrivateChatSession(with peerID: PeerID) {
begunChatSessions.append(peerID)
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
// Unified peer service
var connectedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var blockedPeers: Set<PeerID> = []
var fingerprintsByPeerID: [PeerID: String] = [:]
var peerIDsByNickname: [String: PeerID] = [:]
var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:]
private(set) var toggledFavoritePeers: [PeerID] = []
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) }
func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] }
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] }
// Mesh & Noise sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
var establishedNoiseSessions: Set<PeerID> = []
var activeNoiseSessions: Set<PeerID> = []
var myNoiseFingerprint = "my-fingerprint"
private(set) var triggeredHandshakes: [PeerID] = []
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
establishedNoiseSessions.contains(peerID)
}
func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) }
func noiseIdentityFingerprint() -> String { myNoiseFingerprint }
// Identity store (fingerprints & encryption status)
var verifiedFingerprintSet: Set<String> = []
var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:]
private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = []
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:]
private(set) var invalidatedEncryptionCachePeers: [PeerID?] = []
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
storedFingerprints.append((fingerprint, peerID))
fingerprintsByPeerID[peerID] = fingerprint
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
fingerprintsByPeerID[newPeerID] = fingerprint
}
return fingerprint
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
verifiedFingerprintSet.contains(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
cachedEncryptionStatuses[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
cachedEncryptionStatuses[peerID] = status
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
invalidatedEncryptionCachePeers.append(peerID)
if let peerID {
cachedEncryptionStatuses.removeValue(forKey: peerID)
} else {
cachedEncryptionStatuses.removeAll()
}
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
socialIdentitiesByFingerprint[fingerprint]
}
// Geohash & Nostr
var geoNicknames: [String: String] = [:]
var geohashPeople: [GeoPerson] = []
private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = []
private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = []
var bridgedNostrKeysByNoiseKey: [Data: String] = [:]
func visibleGeohashPeople() -> [GeoPerson] { geohashPeople }
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
registeredNostrKeyMappings.append((pubkey, peerID))
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
bridgedNostrKeysByNoiseKey[noiseKey]
}
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrFavoriteNotifications.append((noisePublicKey, isFavorite))
}
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = []
private(set) var removedFavorites: [Data] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByPeerID[peerID]
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
addedFavorites.append((noiseKey, nostrPublicKey, nickname))
}
func removeFavorite(noiseKey: Data) {
removedFavorites.append(noiseKey)
}
}
// MARK: - Helpers
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
@MainActor
private func makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerIdentityCoordinator` against
/// `MockChatPeerIdentityContext` with no `ChatViewModel`.
struct ChatPeerIdentityCoordinatorContextTests {
@Test @MainActor
func startPrivateChat_runsFullSessionSetupSequence() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
context.fingerprintsByPeerID[peerID] = "fp-alice"
// Chatting with ourselves is a no-op.
coordinator.startPrivateChat(with: context.myPeerID)
#expect(context.begunChatSessions.isEmpty)
coordinator.startPrivateChat(with: peerID)
#expect(context.consolidatedPeers.map(\.peerID) == [peerID])
#expect(context.consolidatedPeers.first?.peerNickname == "alice")
// No Noise session yet -> handshake triggered.
#expect(context.triggeredHandshakes == [peerID])
#expect(context.syncedReadReceiptPeers == [peerID])
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
#expect(context.begunChatSessions == [peerID])
#expect(context.privateStoreSyncCount == 1)
#expect(context.selectionStoreSyncCount == 1)
#expect(context.markedReadPeers == [peerID])
// Established session: no second handshake.
context.noiseSessionStates[peerID] = .established
coordinator.startPrivateChat(with: peerID)
#expect(context.triggeredHandshakes == [peerID])
}
@Test @MainActor
func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.blockedPeers = [peerID]
coordinator.startPrivateChat(with: peerID)
#expect(context.systemMessages.count == 1)
#expect(context.begunChatSessions.isEmpty)
#expect(context.consolidatedPeers.isEmpty)
#expect(context.markedReadPeers.isEmpty)
}
@Test @MainActor
func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let oldPeerID = PeerID(str: "1111111111111111")
let newPeerID = PeerID(str: "2222222222222222")
context.selectedPrivateChatFingerprint = "fp"
context.selectedPrivateChatPeer = oldPeerID
context.connectedPeers = [newPeerID]
context.fingerprintsByPeerID[newPeerID] = "fp"
let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1))
let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2))
context.privateChats[oldPeerID] = [later]
context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2"
context.unreadPrivateMessages = [oldPeerID]
coordinator.updatePrivateChatPeerIfNeeded()
// Old chat is merged into the new peer's chat, deduplicated by id and
// sorted by timestamp; old keys are dropped.
#expect(context.privateChats[oldPeerID] == nil)
#expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"])
#expect(context.selectedPrivateChatPeer == newPeerID)
// Unread moved to the new peer, then cleared for the now-open chat.
#expect(context.unreadPrivateMessages.isEmpty)
}
@Test @MainActor
func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Unknown peer with no fingerprint or session: no handshake yet.
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
#expect(context.cachedEncryptionStatuses[peerID] == .noHandshake)
// Cache hit short-circuits recomputation.
context.noiseSessionStates[peerID] = .established
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
// After invalidation, an established session with a verified
// fingerprint resolves (and re-caches) as verified.
coordinator.invalidateEncryptionCache(for: peerID)
context.fingerprintsByPeerID[peerID] = "fp"
context.verifiedFingerprintSet = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified)
// updateEncryptionStatus publishes to the store and invalidates the cache.
context.establishedNoiseSessions = [peerID]
coordinator.updateEncryptionStatus(for: peerID)
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == nil)
}
@Test @MainActor
func resolveNickname_walksMeshIdentityAndAnonFallbacks() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let meshPeer = PeerID(str: "aabbccddeeff0011")
let identityPeer = PeerID(str: "1234567890abcdef")
let unknownPeer = PeerID(str: "feedfacefeedface")
context.nicknamesByPeerID[meshPeer] = "alice"
#expect(coordinator.resolveNickname(for: meshPeer) == "alice")
context.fingerprintsByPeerID[identityPeer] = "fp"
context.socialIdentitiesByFingerprint["fp"] = SocialIdentity(
fingerprint: "fp",
localPetname: "bob!",
claimedNickname: "bob",
trustLevel: .casual,
isFavorite: false,
isBlocked: false,
notes: nil
)
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
}
@Test @MainActor
func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased()
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())]
context.geoNicknames[pubkey] = "alice"
// Suffixed display-name match.
let bySuffix = coordinator.getPeerIDForNickname("alice#6789")
#expect(bySuffix == PeerID(nostr_: pubkey))
// Base-nickname match via geoNicknames.
let byBase = coordinator.getPeerIDForNickname("ALICE")
#expect(byBase == PeerID(nostr_: pubkey))
#expect(context.registeredNostrKeyMappings.count == 2)
#expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey })
// Mesh channel falls through to the unified peer service.
context.activeChannel = .mesh
let meshPeer = PeerID(str: "1122334455667788")
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}
@Test @MainActor
func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// No prior relationship: adds a favorite, no Nostr notification yet.
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nickname == "Unknown")
#expect(context.nostrFavoriteNotifications.isEmpty)
#expect(coordinator.isFavorite(peerID: peerID) == false)
// They already favorite us: adding sends the mutual notification.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 2)
#expect(context.addedFavorites.last?.nickname == "alice")
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true])
// Existing favorite: toggling removes it and notifies the unfavorite.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
isFavorite: true
)
#expect(coordinator.isFavorite(peerID: peerID) == true)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.removedFavorites == [noiseKey])
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false])
}
}
@@ -0,0 +1,207 @@
//
// ChatPeerListCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` /
// `ChatTransportEventCoordinatorContextTests` exemplars.
//
// Scope note: the network-availability notification now posts through the
// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so
// its gating is covered here; the wall-clock timer-driven reset flows are
// covered by integration-level tests.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerListContext` proving that
/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerListContext: ChatPeerListContext {
// Connection & chat state
var isConnected = false
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var hasTrackedPrivateChatSelection = false
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var cleanupOldReadReceiptsCount = 0
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
}
func cleanupOldReadReceipts() {
cleanupOldReadReceiptsCount += 1
}
// Peers & sessions
var unifiedPeers: [BitchatPeer] = []
var connectedMeshPeers: Set<PeerID> = []
var reachableMeshPeers: Set<PeerID> = []
var activeMeshPeerCountValue = 0
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var updateEncryptionStatusForPeersCount = 0
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) }
func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) }
func activeMeshPeerCount() -> Int { activeMeshPeerCountValue }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 }
// Notifications
private(set) var networkAvailableNotifications: [Int] = []
func notifyNetworkAvailable(peerCount: Int) {
networkAvailableNotifications.append(peerCount)
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.isConnected = true
// Empty list: disconnected, read-receipt hygiene still runs, no sessions.
coordinator.didUpdatePeerList([])
await drainMainActorTasks()
#expect(!context.isConnected)
#expect(context.cleanupOldReadReceiptsCount == 1)
#expect(context.registeredEphemeralSessions.isEmpty)
#expect(context.updateEncryptionStatusForPeersCount == 1)
// Non-empty list: connected, every peer gets an ephemeral session.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerA, peerB])
#expect(context.updateEncryptionStatusForPeersCount == 2)
#expect(context.cleanupOldReadReceiptsCount == 2)
}
@Test @MainActor
func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 0)
context.hasTrackedPrivateChatSelection = true
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
}
@Test @MainActor
func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let currentPeer = PeerID(str: "0011223344556677")
let staleShortPeer = PeerID(str: "8899aabbccddeeff")
let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8))
let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8))
let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32))
context.unifiedPeers = [
BitchatPeer(
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
),
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages,
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
]
coordinator.didUpdatePeerList([currentPeer])
await drainMainActorTasks()
// Stale IDs without a backing conversation are dropped; geo-DM and
// Noise-key IDs with stored messages survive, as does the live peer.
#expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages])
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.connectedMeshPeers = [peerA, peerB]
// First sighting of a mesh-active peer notifies with the mesh peer count.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// The same peer again is not new no repeat notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// A genuinely new peer inside the cooldown window stays silent too.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_meshInactivePeersNeverNotify() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
// Peer present but neither connected nor reachable: no notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications.isEmpty)
}
}
@@ -29,6 +29,21 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
var nostrKeyMapping: [PeerID: String] = [:]
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() {
notifyUIChangedCount += 1
}
@@ -117,6 +132,23 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
embeddedDeliveryAckMessageIDs.append(message.id)
}
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = []
private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
}
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
privateMessageNotifications.append((senderName, message, peerID))
}
// System messages & chat hygiene
private(set) var systemMessages: [String] = []
private(set) var meshOnlySystemMessages: [String] = []
@@ -176,13 +208,33 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
return false
}
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPrivateConversationCoordinator` against
/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the
/// pure-state and ack flows; flows that hit `NotificationService` /
/// `FavoritesPersistenceService` singletons remain covered by the full
/// view-model tests.
/// pure-state and ack flows plus now that notifications and favorites are
/// injected through the context (`notifyPrivateMessage`,
/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) the
/// notification and favorite-transition flows that previously required the
/// live singletons.
struct ChatPrivateConversationCoordinatorContextTests {
@Test @MainActor
@@ -387,4 +439,99 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.sanitizedPeerIDs == [newPeerID])
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
// Not viewing: marked unread and a local notification is posted.
coordinator.handlePrivateMessage(
makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID)
)
#expect(context.unreadPrivateMessages == [peerID])
#expect(context.privateMessageNotifications.count == 1)
#expect(context.privateMessageNotifications.first?.senderName == "alice")
#expect(context.privateMessageNotifications.first?.message == "hi there")
#expect(context.privateMessageNotifications.first?.peerID == peerID)
#expect(context.meshReadReceipts.isEmpty)
// Viewing the chat: a READ ack is sent instead and no notification fires.
context.selectedPrivateChatPeer = peerID
coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID))
#expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"])
#expect(context.sentReadReceipts.contains("pm-2"))
#expect(context.privateMessageNotifications.count == 1)
}
@Test @MainActor
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
// Same state again: store write still happens, but no repeat announcement.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 2)
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
// [UNFAVORITED] transition announces again.
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
}
@Test @MainActor
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCD, count: 32)
let peerID = PeerID(hexData: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
coordinator.sendPrivateMessage("hello bob", to: peerID)
// Offline but mutual favorite with a Nostr key: routed, marked sent,
// and the nickname falls back to the favorite relationship.
#expect(context.routedPrivateMessages.map(\.content) == ["hello bob"])
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent)
#expect(context.privateChats[peerID]?.first?.recipientNickname == "bob")
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32))
coordinator.sendPrivateMessage("hello?", to: peerID)
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.systemMessages.count == 1)
guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else {
Issue.record("expected .failed delivery status")
return
}
}
}
@@ -7,11 +7,11 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: flows that hit process-wide singletons are intentionally not
// exercised here `checkForMentions` / haptics (NotificationService.shared,
// UIApplication) and the geohash branch of `sendPublicRaw`
// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of
// `sendPublicRaw` and all timeline/store/blocking flows are covered.
// Scope note: haptics (UIApplication) and the geohash branch of
// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are
// intentionally not exercised here. `checkForMentions` posts through the
// injected context (`notifyMention(from:message:)`) and is covered, as are
// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows.
//
import Testing
@@ -31,10 +31,14 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var currentGeohash: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var isBatchingPublic = false
private(set) var isBatchingPublic = false
private(set) var notifyUIChangedCount = 0
private(set) var trimMessagesCount = 0
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
@@ -147,6 +151,12 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var geoParticipantCounts: [String: Int] = [:]
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] {
geoPeople
}
@@ -239,6 +249,13 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
prewarmedMessageIDs.append(message.id)
}
// Notifications
private(set) var mentionNotifications: [(sender: String, message: String)] = []
func notifyMention(from sender: String, message: String) {
mentionNotifications.append((sender, message))
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
@@ -492,4 +509,51 @@ struct ChatPublicConversationCoordinatorContextTests {
let unknownHex = String(repeating: "12", count: 32)
#expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4))
}
@Test @MainActor
func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
// A mention of my nickname from someone else notifies.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-1",
sender: "alice",
content: "hey @me",
timestamp: Date(),
isRelay: false,
mentions: ["me"]
)
)
#expect(context.mentionNotifications.count == 1)
#expect(context.mentionNotifications.first?.sender == "alice")
#expect(context.mentionNotifications.first?.message == "hey @me")
// Mentioning someone else does not notify.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-2",
sender: "alice",
content: "hey @bob",
timestamp: Date(),
isRelay: false,
mentions: ["bob"]
)
)
#expect(context.mentionNotifications.count == 1)
// My own message mentioning myself does not notify.
coordinator.checkForMentions(
BitchatMessage(
id: "mention-3",
sender: "me",
content: "talking about @me",
timestamp: Date(),
isRelay: false,
mentions: ["me"]
)
)
#expect(context.mentionNotifications.count == 1)
}
}
@@ -0,0 +1,331 @@
//
// ChatTransportEventCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatTransportEventCoordinator` against a mock
// `ChatTransportEventContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the coordinator hops every event onto the main actor via an
// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are
// mockable no singletons are involved at this layer.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatTransportEventContext` proving that
/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatTransportEventContext: ChatTransportEventContext {
// Connection & chat state
var isConnected = false
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
private(set) var unmarkedReadReceiptBatches: [[String]] = []
private(set) var notifyUIChangedCount = 0
func unmarkReadReceiptsSent(_ ids: [String]) {
unmarkedReadReceiptBatches.append(ids)
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
// Inbound message handling
var blockedMessageIDs: Set<String> = []
private(set) var handledPrivateMessages: [BitchatMessage] = []
private(set) var handledPublicMessages: [BitchatMessage] = []
private(set) var mentionCheckedMessageIDs: [String] = []
private(set) var hapticMessageIDs: [String] = []
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
blockedMessageIDs.contains(message.id)
}
func handlePrivateMessage(_ message: BitchatMessage) {
handledPrivateMessages.append(message)
}
func handlePublicMessage(_ message: BitchatMessage) {
handledPublicMessages.append(message)
}
func checkForMentions(_ message: BitchatMessage) {
mentionCheckedMessageIDs.append(message.id)
}
func sendHapticFeedback(for message: BitchatMessage) {
hapticMessageIDs.append(message.id)
}
func parseMentions(from content: String) -> [String] {
content.contains("@me") ? ["me"] : []
}
// Peer identity & sessions
var blockedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var stablePeerIDCache: [PeerID: PeerID] = [:]
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var removedEphemeralSessions: [PeerID] = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) }
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] }
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) {
stablePeerIDCache[shortPeerID] = stablePeerID
}
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] }
// Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID))
}
// Delivery status
var applyMessageDeliveryStatusResult = true
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = []
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
appliedDeliveryStatuses.append((messageID, status))
return applyMessageDeliveryStatusResult
}
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
deliveryStatusesByMessageID[messageID]
}
// Verification payloads
private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = []
private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = []
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
verifyChallengePayloads.append((peerID, payload))
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(
id: String,
sender: String = "alice",
content: String = "hello",
isPrivate: Bool = false,
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "me" : nil,
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatTransportEventCoordinator` against
/// `MockChatTransportEventContext` with no `ChatViewModel`.
struct ChatTransportEventCoordinatorContextTests {
@Test @MainActor
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
}
@Test @MainActor
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "aabbccdd00112233")
coordinator.didReceivePublicMessage(
from: peerID,
nickname: "alice",
content: " hi @me ",
timestamp: Date(),
messageID: "m1"
)
await drainMainActorTasks()
#expect(context.handledPublicMessages.count == 1)
let message = context.handledPublicMessages[0]
#expect(message.content == "hi @me")
#expect(message.mentions == ["me"])
#expect(message.senderPeerID == peerID)
#expect(context.hapticMessageIDs == ["m1"])
}
@Test @MainActor
func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
coordinator.didConnectToPeer(peerID)
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerID])
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.flushedOutboxPeerIDs == [peerID])
#expect(context.notifyUIChangedCount == 1)
// Their messages' read receipts are un-marked on disconnect so READ
// acks can be re-sent after reconnect; our own messages are not.
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.removedEphemeralSessions == [peerID])
#expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xCD, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
// No cached stable ID: it must be derived from the Noise session key.
context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.selectedPrivateChatPeer = peerID
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
#expect(context.privateChats[peerID] == nil)
#expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"])
// Sender IDs migrate to the stable peer ID, except our own.
#expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID)
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID)
#expect(context.selectedPrivateChatPeer == stablePeerID)
#expect(context.unreadPrivateMessages == [stablePeerID])
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
}
@Test @MainActor
func noisePayloads_driveDeliveryStatusAcksAndVerification() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
let noiseKey = Data(repeating: 0x44, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
// Inbound private message: decoded, handled, and delivery-acked.
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm-1"])
#expect(context.handledPrivateMessages.first?.sender == "alice")
#expect(context.meshDeliveryAcks.count == 1)
#expect(context.meshDeliveryAcks.first?.messageID == "pm-1")
// Delivered / read acks resolve the display name from the unified peer.
coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date())
await drainMainActorTasks()
#expect(context.appliedDeliveryStatuses.count == 2)
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
#expect(to == "alice")
} else {
Issue.record("expected .delivered status")
}
if case .read(let by, _) = context.appliedDeliveryStatuses[1].status {
#expect(by == "alice")
} else {
Issue.record("expected .read status")
}
// Verification payloads are forwarded untouched.
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date())
coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date())
await drainMainActorTasks()
#expect(context.verifyChallengePayloads.count == 1)
#expect(context.verifyResponsePayloads.count == 1)
// Blocked peers' private messages are dropped (no handling, no ack).
context.blockedPeers = [peerID]
coordinator.didReceiveNoisePayload(
from: peerID,
type: .privateMessage,
payload: packet.encode() ?? Data(),
timestamp: Date()
)
await drainMainActorTasks()
#expect(context.handledPrivateMessages.count == 1)
#expect(context.meshDeliveryAcks.count == 1)
}
}
@@ -0,0 +1,321 @@
//
// ChatVerificationCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatVerificationCoordinator` against a mock
// `ChatVerificationContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: `handleVerifyResponsePayload` requires a real Ed25519
// signature; it remains covered by the full view-model/integration tests.
// Challenge handling, QR kickoff, fingerprint verification, verified-set
// loading, and the mutual-verification notification (posted through the
// injected context) are covered here (`VerificationService.shared` is only
// used for pure payload build/parse).
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatVerificationContext` proving that
/// `ChatVerificationCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatVerificationContext: ChatVerificationContext {
// Fingerprints & verification state
var fingerprintsByPeerID: [PeerID: String] = [:]
var verifiedFingerprints: Set<String> = []
var persistedFingerprints: Set<String> = []
private(set) var identityVerifiedCalls: [(fingerprint: String, verified: Bool)] = []
private(set) var storedVerifiedCalls: [(fingerprint: String, verified: Bool)] = []
private(set) var saveIdentityStateCount = 0
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func persistedVerifiedFingerprints() -> Set<String> { persistedFingerprints }
func setIdentityVerified(fingerprint: String, verified: Bool) {
identityVerifiedCalls.append((fingerprint, verified))
}
func setStoredVerified(_ fingerprint: String, verified: Bool) {
storedVerifiedCalls.append((fingerprint, verified))
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func saveIdentityState() { saveIdentityStateCount += 1 }
// Encryption status
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
private(set) var invalidatedEncryptionCachePeers: [PeerID?] = []
private(set) var notifyUIChangedCount = 0
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
func updateEncryptionStatus(for peerID: PeerID) {
updatedEncryptionStatusPeers.append(peerID)
}
func invalidateEncryptionCache(for peerID: PeerID?) {
invalidatedEncryptionCachePeers.append(peerID)
}
func notifyUIChanged() { notifyUIChangedCount += 1 }
// Peers
var unifiedPeers: [BitchatPeer] = []
var unifiedFavorites: [BitchatPeer] = []
private(set) var stablePeerIDCache: [PeerID: PeerID] = [:]
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? {
unifiedPeers.first { $0.peerID == peerID }
}
func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" }
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] }
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) {
stablePeerIDCache[shortPeerID] = stablePeerID
}
// Noise sessions & verification transport
var myNoiseStaticKey = Data(repeating: 0x42, count: 32)
var establishedNoiseSessions: Set<PeerID> = []
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
installedCallbacks = (onPeerAuthenticated, onHandshakeRequired)
}
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] }
func noiseStaticPublicKeyData() -> Data { myNoiseStaticKey }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
establishedNoiseSessions.contains(peerID)
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA))
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentResponses.append((peerID, noiseKeyHex, nonceA))
}
// Notifications
private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = []
func postLocalNotification(title: String, body: String, identifier: String) {
postedLocalNotifications.append((title, body, identifier))
}
}
// MARK: - Helpers
/// Builds the raw verify-challenge TLV as it arrives at the coordinator
/// (i.e. with the `NoisePayload` type byte already stripped).
private func makeVerifyChallengeTLV(noiseKeyHex: String, nonceA: Data) -> Data {
var tlv = Data()
tlv.append(0x01)
tlv.append(UInt8(noiseKeyHex.count))
tlv.append(Data(noiseKeyHex.utf8))
tlv.append(0x02)
tlv.append(UInt8(nonceA.count))
tlv.append(nonceA)
return tlv
}
private func makeVerificationQR(noiseKeyHex: String) -> VerificationService.VerificationQR {
VerificationService.VerificationQR(
v: 1,
noiseKeyHex: noiseKeyHex,
signKeyHex: "00" + String(repeating: "ab", count: 31),
npub: nil,
nickname: "alice",
ts: 0,
nonceB64: "",
sigHex: ""
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatVerificationCoordinator` against
/// `MockChatVerificationContext` with no `ChatViewModel`.
struct ChatVerificationCoordinatorContextTests {
@Test @MainActor
func verifyAndUnverifyFingerprint_updateBothStoresAndStatus() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Unknown fingerprint: nothing happens.
coordinator.verifyFingerprint(for: peerID)
#expect(context.identityVerifiedCalls.isEmpty)
context.fingerprintsByPeerID[peerID] = "fp"
coordinator.verifyFingerprint(for: peerID)
coordinator.unverifyFingerprint(for: peerID)
#expect(context.identityVerifiedCalls.map(\.fingerprint) == ["fp", "fp"])
#expect(context.identityVerifiedCalls.map(\.verified) == [true, false])
#expect(context.storedVerifiedCalls.map(\.verified) == [true, false])
#expect(context.saveIdentityStateCount == 2)
#expect(context.updatedEncryptionStatusPeers == [peerID, peerID])
}
@Test @MainActor
func beginQRVerification_sendsChallengeOrTriggersHandshake() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCD, count: 32)
let peerID = PeerID(str: "1122334455667788")
let qr = makeVerificationQR(noiseKeyHex: noiseKey.hexEncodedString())
// No matching peer -> not started.
#expect(!coordinator.beginQRVerification(with: qr))
// Matching peer without an established session -> handshake first.
context.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")]
#expect(coordinator.beginQRVerification(with: qr))
#expect(context.triggeredHandshakes == [peerID])
#expect(context.sentChallenges.isEmpty)
// Already pending -> short-circuits without re-triggering.
#expect(coordinator.beginQRVerification(with: qr))
#expect(context.triggeredHandshakes == [peerID])
// Fresh coordinator with an established session -> immediate challenge.
let context2 = MockChatVerificationContext()
context2.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")]
context2.establishedNoiseSessions = [peerID]
let coordinator2 = ChatVerificationCoordinator(context: context2)
#expect(coordinator2.beginQRVerification(with: qr))
#expect(context2.sentChallenges.count == 1)
#expect(context2.sentChallenges.first?.noiseKeyHex == qr.noiseKeyHex)
#expect(context2.triggeredHandshakes.isEmpty)
}
@Test @MainActor
func handleVerifyChallengePayload_respondsOncePerNonceForOurKeyOnly() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let myHex = context.myNoiseStaticKey.hexEncodedString()
let nonce = Data(repeating: 0x07, count: 16)
let payload = makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: nonce)
coordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
#expect(context.sentResponses.count == 1)
#expect(context.sentResponses.first?.noiseKeyHex.lowercased() == myHex)
#expect(context.sentResponses.first?.nonceA == nonce)
// Same nonce again: deduplicated, no second response.
coordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
#expect(context.sentResponses.count == 1)
// A challenge for someone else's key is ignored.
let otherHex = Data(repeating: 0x99, count: 32).hexEncodedString()
let otherPayload = makeVerifyChallengeTLV(
noiseKeyHex: otherHex,
nonceA: Data(repeating: 0x08, count: 16)
)
coordinator.handleVerifyChallengePayload(from: peerID, payload: otherPayload)
#expect(context.sentResponses.count == 1)
}
@Test @MainActor
func loadVerifiedFingerprints_syncsPersistedSetAndRefreshesUI() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
context.persistedFingerprints = ["fp1", "fp2"]
coordinator.loadVerifiedFingerprints()
#expect(context.verifiedFingerprints == ["fp1", "fp2"])
#expect(context.invalidatedEncryptionCachePeers == [nil])
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func installedNoiseCallbacks_publishStatusAndStableIDs() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x33, count: 32)
context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.verifiedFingerprints = ["fp-verified"]
coordinator.setupNoiseCallbacks()
let callbacks = try? #require(context.installedCallbacks)
// Authenticated with a verified fingerprint -> verified status and a
// cached stable peer ID derived from the session key.
callbacks?.onPeerAuthenticated(peerID, "fp-verified")
await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
// Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID)
await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseHandshaking)
}
@Test @MainActor
func handleVerifyChallengePayload_postsMutualVerificationToastOncePerMinute() async {
let context = MockChatVerificationContext()
let coordinator = ChatVerificationCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let myHex = context.myNoiseStaticKey.hexEncodedString()
context.fingerprintsByPeerID[peerID] = "fp-mutual"
context.verifiedFingerprints = ["fp-mutual"]
coordinator.handleVerifyChallengePayload(
from: peerID,
payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x07, count: 16))
)
// Already-verified peer challenging us: mutual-verification toast.
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "Mutual verification")
#expect(context.postedLocalNotifications.first?.body.hasSuffix("verified each other") == true)
#expect(context.postedLocalNotifications.first?.identifier.hasPrefix("verify-mutual-") == true)
// A fresh nonce inside the per-fingerprint toast cooldown stays silent.
coordinator.handleVerifyChallengePayload(
from: peerID,
payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x08, count: 16))
)
#expect(context.postedLocalNotifications.count == 1)
#expect(context.sentResponses.count == 2)
}
}
/// The installed callbacks hop through `DispatchQueue.main.async`; tests must
/// let that queue drain before asserting.
@MainActor
private func waitForMainQueue() async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async { continuation.resume() }
}
}
@@ -388,6 +388,12 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
private(set) var notifyUIChangedCount = 0
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() {
notifyUIChangedCount += 1
}
+128 -23
View File
@@ -225,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests {
// "Check geohash (Nostr) blocks using mapping to full pubkey"
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID)
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
// Force isGeoChat/isGeoDM check to be true by setting prefix?
@@ -235,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests {
// We need a peerID that looks like geo.
let geoPeerID = PeerID(nostr_: hexPubkey)
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID)
let geoMessage = BitchatMessage(
id: "msg-geo-blocked",
@@ -398,26 +398,6 @@ struct ChatViewModelNostrExtensionTests {
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor
func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity())
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidGiftWrap = giftWrap
invalidGiftWrap.sig = String(repeating: "0", count: 128)
viewModel.handleNostrMessage(invalidGiftWrap)
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
viewModel.handleNostrMessage(giftWrap)
#expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
}
@Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel()
@@ -777,7 +757,7 @@ struct ChatViewModelGeoDMTests {
let convKey = PeerID(nostr_: recipientHex)
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.sendGeohashDM("hello", to: convKey)
@@ -817,6 +797,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)
struct ChatViewModelMediaTransferTests {
@@ -0,0 +1,196 @@
//
// GeoChannelCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` exemplar.
//
// Scope note: the location/bookmark managers are real `LocationStateManager`
// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation
// seams. `TorManager` has no test seam (private init singleton); sampling
// tests pin `TorManager.shared` to foreground (its default) so the
// begin-sampling branch is deterministic.
//
import Testing
import Foundation
import CoreLocation
import Tor
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `GeoChannelContext` proving that
/// `GeoChannelCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockGeoChannelContext: GeoChannelContext {
private(set) var switchedChannels: [ChannelID] = []
private(set) var beginSamplingCalls: [[String]] = []
private(set) var endSamplingCount = 0
func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) }
func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) }
func endGeohashSampling() { endSamplingCount += 1 }
}
// MARK: - CoreLocation Seams
private final class StubLocationManaging: LocationStateManaging {
weak var delegate: CLLocationManagerDelegate?
var desiredAccuracy: CLLocationAccuracy = 0
var distanceFilter: CLLocationDistance = 0
var authorizationStatus: CLAuthorizationStatus = .denied
func requestWhenInUseAuthorization() {}
func requestLocation() {}
func startUpdatingLocation() {}
func stopUpdatingLocation() {}
}
private final class StubLocationGeocoder: LocationStateGeocoding {
func cancelGeocode() {}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
completionHandler(nil, nil)
}
}
// MARK: - Helpers
@MainActor
private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager {
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
let defaults = storage ?? UserDefaults(suiteName: suiteName)!
if storage == nil {
defaults.removePersistentDomain(forName: suiteName)
}
return LocationStateManager(
storage: defaults,
locationManager: StubLocationManaging(),
geocoder: StubLocationGeocoder(),
shouldInitializeCoreLocation: false
)
}
/// Polls until `condition` holds, letting main-actor tasks and main-queue
/// Combine hops drain in between.
@MainActor
private func waitUntil(_ condition: () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() { return true }
await Task.yield()
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no
/// `ChatViewModel`.
struct GeoChannelCoordinatorContextTests {
@Test @MainActor
func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws {
let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru"))
storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected")
let locationManager = makeLocationManager(storage: storage)
let context = MockGeoChannelContext()
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
context: context
)
defer { withExtendedLifetime(coordinator) {} }
// The persisted selection is announced and, with no regional or
// bookmarked geohashes, sampling ends rather than begins.
#expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 })
#expect(context.switchedChannels.allSatisfy { $0 == persisted })
#expect(context.beginSamplingCalls.isEmpty)
}
@Test @MainActor
func selectingChannel_propagatesSwitchToContext() async {
let locationManager = makeLocationManager()
let context = MockGeoChannelContext()
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
context: context
)
defer { withExtendedLifetime(coordinator) {} }
#expect(await waitUntil { context.switchedChannels.contains(.mesh) })
let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq"))
locationManager.select(target)
#expect(await waitUntil { context.switchedChannels.last == target })
}
@Test @MainActor
func bookmarkChanges_beginAndEndGeohashSampling() async {
TorManager.shared.setAppForeground(true)
let locationManager = makeLocationManager()
let context = MockGeoChannelContext()
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
context: context
)
// No geohashes yet: only end-sampling has run.
#expect(await waitUntil { context.endSamplingCount > 0 })
#expect(context.beginSamplingCalls.isEmpty)
// Bookmarking a geohash starts sampling it.
locationManager.toggleBookmark("u4pruydq")
#expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] })
// Removing the last bookmark ends sampling again, and a manual
// refresh keeps reporting the empty state.
let endCountBeforeRemoval = context.endSamplingCount
locationManager.toggleBookmark("u4pruydq")
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval })
let endCountBeforeRefresh = context.endSamplingCount
coordinator.refreshSampling()
#expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh })
}
@Test @MainActor
func releasedContext_isHeldWeaklyAndSafelyIgnored() async {
let locationManager = makeLocationManager()
var context: MockGeoChannelContext? = MockGeoChannelContext()
weak var weakContext = context
let coordinator = GeoChannelCoordinator(
locationManager: locationManager,
bookmarksStore: locationManager,
torManager: TorManager.shared,
context: context!
)
#expect(await waitUntil { context?.switchedChannels.isEmpty == false })
// The coordinator must not keep the owner alive (it is owned by it).
context = nil
#expect(weakContext == nil)
// Events after the owner is gone are safely dropped.
locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru")))
locationManager.toggleBookmark("u4pruydq")
coordinator.refreshSampling()
for _ in 0..<10 { await Task.yield() }
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
@@ -0,0 +1,529 @@
//
// PerformanceBaselineTests.swift
// bitchatTests
//
// Performance baselines for hot paths so regressions are measured, not
// guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with
// NO recorded baselines these tests record timing in CI logs but never
// fail on timing, so they cannot flake.
//
// Each benchmark builds its fixtures OUTSIDE the measure block with fixed
// seeds/content so every iteration does deterministic work: no network,
// no Tor, no sleeps.
//
// Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable;
// runs by default.
//
import XCTest
import SwiftUI
import BitFoundation
@testable import bitchat
@MainActor
final class PerformanceBaselineTests: XCTestCase {
override func setUpWithError() throws {
try XCTSkipIf(
ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1",
"Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES"
)
}
override func tearDown() {
// Drain main-queue tasks spawned by coordinators (handlePublicMessage
// hops etc.) so they don't bleed into the next test's measure block.
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
super.tearDown()
}
/// Reports one human-readable throughput line per benchmark so CI logs
/// are readable without parsing XCTest's measure output.
private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
guard !samples.isEmpty else { return }
let avg = samples.reduce(0, +) / Double(samples.count)
let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity
print(String(
format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)",
name, opsPerSec, unit, avg * 1000, operations, samples.count
))
}
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
// first-seen path on every iteration. Kept alive so the weakly
// captured Task hops stay valid.
var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = []
var samples: [TimeInterval] = []
measure {
let context = PerfNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
keepAlive.append((context, coordinator))
let start = Date()
for event in events {
coordinator.inbound.handleNostrEvent(event)
}
samples.append(Date().timeIntervalSince(start))
XCTAssertEqual(context.processedEventCount, events.count)
}
reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events")
}
// MARK: - 1b. Nostr inbound event handling (duplicate events)
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
// Pre-warm: every event is now recorded as processed.
for event in events {
coordinator.inbound.handleNostrEvent(event)
}
XCTAssertEqual(context.processedEventCount, events.count)
var samples: [TimeInterval] = []
measure {
let start = Date()
for event in events {
coordinator.inbound.handleNostrEvent(event)
}
samples.append(Date().timeIntervalSince(start))
}
// Dedup held: nothing was re-processed.
XCTAssertEqual(context.processedEventCount, events.count)
reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events")
}
// MARK: - 2. BLE inbound packet pipeline (decode + dedup)
/// Binary encode/decode round trip plus `MessageDeduplicator` at
/// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each
/// dedup-checked twice (first-seen insert, then duplicate hit) the way
/// relayed packets arrive on multiple links.
func testBLEInboundPacketPipeline() throws {
var rng = SeededGenerator(seed: 0xB17C4A7)
let baseTimestamp = UInt64(1_700_000_000_000)
var packets: [BitchatPacket] = []
var dedupIDs: [String] = []
packets.reserveCapacity(1000)
dedupIDs.reserveCapacity(1000)
for index in 0..<1000 {
let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) })
let payloadSize = Int.random(in: 100...300, using: &rng)
let payload = Data((0..<payloadSize).map { _ in UInt8.random(in: 0...255, using: &rng) })
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: senderID,
recipientID: nil,
timestamp: baseTimestamp + UInt64(index),
payload: payload,
signature: nil,
ttl: 7
)
packets.append(packet)
// Same dedup key shape BLEService uses: sender-timestamp-type.
dedupIDs.append("\(senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)")
}
let deduplicator = MessageDeduplicator()
var samples: [TimeInterval] = []
measure {
deduplicator.reset() // identical work every pass
let start = Date()
var decodedCount = 0
var duplicateCount = 0
for (index, packet) in packets.enumerated() {
guard let data = packet.toBinaryData(),
let decoded = BitchatPacket.from(data) else { continue }
decodedCount += 1
_ = decoded.payload.count
if deduplicator.isDuplicate(dedupIDs[index]) { duplicateCount += 1 }
}
for id in dedupIDs where deduplicator.isDuplicate(id) {
duplicateCount += 1
}
samples.append(Date().timeIntervalSince(start))
XCTAssertEqual(decodedCount, packets.count)
XCTAssertEqual(duplicateCount, packets.count)
}
reportThroughput("bleInbound.roundTripAndDedup", samples: samples, operations: packets.count, unit: "packets")
}
// MARK: - 3. GCS sync filters
/// `GCSFilter.buildFilter` + `decodeToSortedSet` at the production gossip
/// config (`TransportConfig.syncGCSMaxBytes` / `syncGCSTargetFpr`) with
/// 1000 candidate packet IDs (the filter caps to its byte budget).
func testGCSFilterBuildAndDecode() {
var rng = SeededGenerator(seed: 0x6C5F11)
let ids: [Data] = (0..<1000).map { _ in
Data((0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) })
}
let maxBytes = TransportConfig.syncGCSMaxBytes
let targetFpr = TransportConfig.syncGCSTargetFpr
let repsPerPass = 20
var samples: [TimeInterval] = []
measure {
let start = Date()
var decodedTotal = 0
for _ in 0..<repsPerPass {
let params = GCSFilter.buildFilter(ids: ids, maxBytes: maxBytes, targetFpr: targetFpr)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
decodedTotal += decoded.count
XCTAssertLessThanOrEqual(params.data.count, maxBytes)
}
samples.append(Date().timeIntervalSince(start))
XCTAssertGreaterThan(decodedTotal, 0)
}
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
}
// MARK: - 4a. Delivery location index (incremental path)
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
/// location index: 2000 public + 50x40 private messages, 500 status
/// updates per pass. Statuses alternate sent <-> delivered so every call
/// performs a real update (never the skip path).
func testDeliveryStatusIncrementalUpdates() {
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
let coordinator = ChatDeliveryCoordinator(context: context)
// Warm the index so the measure block exercises the incremental path.
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
// 250 public + 250 private IDs spread across the corpus.
var targetIDs: [String] = []
for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) }
let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id }
for (offset, peer) in peerIDs.enumerated() where offset < 25 {
guard let chat = context.privateChats[peer] else { continue }
for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) }
}
XCTAssertEqual(targetIDs.count, 500)
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
var toggle = false
var samples: [TimeInterval] = []
measure {
toggle.toggle()
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
let start = Date()
var updated = 0
for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) {
updated += 1
}
samples.append(Date().timeIntervalSince(start))
XCTAssertEqual(updated, targetIDs.count)
}
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
}
// MARK: - 4b. Delivery location index (rebuild path)
/// The expensive path: a non-append insertion (out-of-order arrival at
/// the head of the timeline) invalidates the index, so the next status
/// update triggers a full rebuild over ~4000 message locations.
func testDeliveryStatusIndexRebuild() {
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
let coordinator = ChatDeliveryCoordinator(context: context)
// Warm the index before the first insertion invalidates it.
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
// Pre-built head insertions, one consumed per measure pass.
let insertions: [BitchatMessage] = (0..<32).map { i in
BitchatMessage(
id: "insert-\(i)",
sender: "alice",
content: "out-of-order arrival \(i)",
timestamp: Date(timeIntervalSince1970: 1_690_000_000),
isRelay: false
)
}
let probeID = context.messages[1000].id
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
var pass = 0
var toggle = false
var samples: [TimeInterval] = []
measure {
precondition(pass < insertions.count, "add more pre-built insertions")
context.messages.insert(insertions[pass], at: 0)
pass += 1
toggle.toggle()
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
let start = Date()
let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status)
samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(updated)
}
reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds")
}
// MARK: - 5. Message formatting
/// `MessageFormattingEngine.formatMessage` over 200 messages with
/// mentions, hashtags, and URLs. Formatting caches per message, so each
/// measure pass consumes a fresh pre-built batch (cache-miss path, which
/// is the cost paid when messages first render).
func testMessageFormatting() {
let context = PerfFormattingContext(nickname: "carol")
let batchCount = 16 // > XCTest's default 10 measure iterations
let batchSize = 200
let contents: [(String, [String]?)] = [
("hello mesh, anyone around tonight?", nil),
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
]
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in
let (content, mentions) = contents[i % contents.count]
return BitchatMessage(
id: "fmt-\(batch)-\(i)",
sender: "alice#a1b\(i % 10)",
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(i)),
isRelay: false,
senderPeerID: PeerID(str: "abcdef123456789\(i % 10)"),
mentions: mentions
)
}
}
var pass = 0
var samples: [TimeInterval] = []
measure {
precondition(pass < batches.count, "add more pre-built batches")
let batch = batches[pass]
pass += 1
let start = Date()
var formattedCharacters = 0
for message in batch {
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
formattedCharacters += formatted.characters.count
}
samples.append(Date().timeIntervalSince(start))
XCTAssertGreaterThan(formattedCharacters, 0)
}
reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages")
}
// MARK: - Fixtures
/// Builds deterministic signed kind-20000 geohash events. Content cycles
/// through realistic chat lines; a handful of sender identities mimics a
/// busy channel.
private static func makeSignedGeohashEvents(count: Int) throws -> [NostrEvent] {
let senders = try (0..<8).map { _ in try NostrIdentity.generate() }
let lines = [
"hello from the geohash",
"anyone near the station?",
"@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat",
"teleport check, who's local?",
]
return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent(
content: "\(lines[i % lines.count]) [\(i)]",
geohash: "u4pruyd",
senderIdentity: senders[i % senders.count],
nickname: "peer\(i % senders.count)",
teleported: i % 25 == 0
)
}
}
}
// MARK: - Deterministic RNG
private struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) { state = seed }
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
}
}
// MARK: - Mock ChatNostrContext
/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator`
/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel`
/// (mirrors `ChatNostrCoordinatorContextTests`).
/// Callbacks are cheap dictionary/array operations so the measured cost is
/// the coordinator's own pipeline.
@MainActor
private final class PerfNostrContext: ChatNostrContext {
var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd"))
var currentGeohash: String? = "u4pruyd"
var geoSubscriptionID: String?
var geoDmSubscriptionID: String?
var geoSamplingSubs: [String: String] = [:]
var lastGeoNotificationAt: [String: Date] = [:]
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] = []
func resetPublicMessagePipeline() {}
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
func refreshVisibleMessages(from channel: ChannelID?) {}
func addPublicSystemMessage(_ content: String) {}
func drainPendingGeohashSystemMessages() -> [String] { [] }
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
func synchronizePublicConversationStore(forGeohash geohash: String) {}
private(set) var handledPublicMessageCount = 0
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
func checkForMentions(_ message: BitchatMessage) {}
func sendHapticFeedback(for message: BitchatMessage) {}
func parseMentions(from content: String) -> [String] {
MessageFormattingEngine.extractMentions(from: content)
}
var selectedPrivateChatPeer: PeerID?
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 handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
func startPrivateChat(with peerID: PeerID) {}
private struct NoIdentity: Error {}
var geohashIdentities: [String: NostrIdentity] = [:]
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
guard let identity = geohashIdentities[geohash] else { throw NoIdentity() }
return identity
}
func currentNostrIdentity() -> NostrIdentity? { nil }
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false }
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" }
private var processedNostrEventIDs: Set<String> = []
var processedEventCount: Int { processedNostrEventIDs.count }
func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) }
func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) }
func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() }
var geoNicknames: [String: String] = [:]
private var teleportedKeys: Set<String> = []
var teleportedGeoCount: Int { teleportedKeys.count }
func startGeoParticipantRefreshTimer() {}
func stopGeoParticipantRefreshTimer() {}
func setActiveParticipantGeohash(_ geohash: String?) {}
private(set) var participantRecords = 0
func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 }
func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 }
func geoParticipantCount(for geohash: String) -> Int { 0 }
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
func clearTeleportedGeo() { teleportedKeys.removeAll() }
func clearGeoNicknames() { geoNicknames.removeAll() }
func visibleGeohashPeople() -> [GeoPerson] { [] }
var isTeleported = false
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false }
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil }
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] }
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {}
func postLocalNotification(title: String, body: String, identifier: String) {}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {}
}
// MARK: - Mock ChatDeliveryContext
@MainActor
private final class PerfDeliveryContext: ChatDeliveryContext {
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
var sentReadReceipts: Set<String> = []
var isStartupPhase: Bool { false }
func notifyUIChanged() {}
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
/// deterministic IDs and timestamps.
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
let context = PerfDeliveryContext()
let base = Date(timeIntervalSince1970: 1_700_000_000)
context.messages = (0..<publicCount).map { i in
BitchatMessage(
id: "pub-\(i)",
sender: "peer\(i % 20)",
content: "public message number \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
)
}
for p in 0..<peerCount {
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
BitchatMessage(
id: "dm-\(p)-\(i)",
sender: "peer\(p)",
content: "private message \(i) for peer \(p)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: peerID
)
}
}
return context
}
}
// MARK: - Mock MessageFormattingContext
@MainActor
private final class PerfFormattingContext: MessageFormattingContext {
let nickname: String
init(nickname: String) { self.nickname = nickname }
func isSelfMessage(_ message: BitchatMessage) -> Bool { false }
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue }
func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") }
}
@@ -0,0 +1,82 @@
import Testing
import Foundation
@testable import bitchat
struct SchnorrConcurrencyRepro {
@Test func concurrentVerificationOfValidEventAlwaysSucceeds() async throws {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", "sn"]],
content: "concurrency probe"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
#expect(signed.isValidSignature())
let failures = await withTaskGroup(of: Int.self) { group in
for _ in 0..<8 {
group.addTask {
var localFailures = 0
for _ in 0..<250 {
if !signed.isValidSignature() { localFailures += 1 }
}
return localFailures
}
}
var total = 0
for await f in group { total += f }
return total
}
#expect(failures == 0, "Schnorr verification returned false \(failures)/2000 times under concurrency")
}
@Test func verificationSurvivesConcurrentSigningAndKeyGeneration() async throws {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", "sn"]],
content: "concurrency probe"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
#expect(signed.isValidSignature())
// Signing and key generation may mutate shared library state
// (secp256k1 context randomization); verification must stay correct
// while they run on other tasks.
let failures = await withTaskGroup(of: Int.self) { group in
for worker in 0..<8 {
group.addTask {
if worker < 4 {
var localFailures = 0
for _ in 0..<250 {
if !signed.isValidSignature() { localFailures += 1 }
}
return localFailures
} else {
for i in 0..<100 {
if let id = try? NostrIdentity.generate() {
let e = NostrEvent(
pubkey: id.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [],
content: "signer \(worker)-\(i)"
)
_ = try? e.sign(with: id.schnorrSigningKey())
}
}
return 0
}
}
}
var total = 0
for await f in group { total += f }
return total
}
#expect(failures == 0, "Schnorr verification returned false \(failures)/1000 times while signing ran concurrently")
}
}
@@ -101,7 +101,8 @@ public final class SecureLogger {
// MARK: - Global Threshold
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
private static let minimumLevel: LogLevel = {
/// Internal-settable so tests can verify level filtering; app code should not mutate it.
internal static var minimumLevel: LogLevel = {
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
switch env {
case "debug": return .debug
@@ -121,23 +122,33 @@ public final class SecureLogger {
public extension SecureLogger {
// Each wrapper checks the level BEFORE evaluating the autoclosure so
// filtered messages never pay for string interpolation this matters on
// hot paths that log per packet/event. Debug compiles out of release
// builds entirely (the core log() drops .debug there anyway).
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
#if DEBUG
guard shouldLog(.debug) else { return }
log(message(), category: category, level: .debug, file: file, line: line, function: function)
#endif
}
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(.info) else { return }
log(message(), category: category, level: .info, file: file, line: line, function: function)
}
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(.warning) else { return }
log(message(), category: category, level: .warning, file: file, line: line, function: function)
}
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(.error) else { return }
log(message(), category: category, level: .error, file: file, line: line, function: function)
}
@@ -0,0 +1,54 @@
import XCTest
@testable import BitLogger
/// The public logging wrappers must not evaluate their message autoclosure
/// when the level is filtered out hot paths log per packet/event, and
/// building a discarded interpolated string on each call is real overhead.
final class LogLevelFilteringTests: XCTestCase {
private final class EvaluationCounter {
var count = 0
func message() -> String {
count += 1
return "expensive interpolation"
}
}
private var originalLevel: SecureLogger.LogLevel!
override func setUp() {
super.setUp()
originalLevel = SecureLogger.minimumLevel
}
override func tearDown() {
SecureLogger.minimumLevel = originalLevel
super.tearDown()
}
func testFilteredDebugMessageIsNeverEvaluated() {
SecureLogger.minimumLevel = .info
let counter = EvaluationCounter()
SecureLogger.debug(counter.message())
XCTAssertEqual(counter.count, 0, "Filtered debug message should not be constructed")
}
func testFilteredInfoMessageIsNeverEvaluated() {
SecureLogger.minimumLevel = .error
let counter = EvaluationCounter()
SecureLogger.info(counter.message())
XCTAssertEqual(counter.count, 0, "Filtered info message should not be constructed")
}
func testEnabledLevelStillEvaluatesMessage() {
SecureLogger.minimumLevel = .debug
let counter = EvaluationCounter()
SecureLogger.warning(counter.message())
XCTAssertEqual(counter.count, 1, "Enabled levels must still log")
}
}