mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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.).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
// 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 scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
Task { @MainActor in
|
||||
work()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopMeshServices() {
|
||||
meshService.stopServices()
|
||||
}
|
||||
|
||||
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 viewModel: ChatViewModel
|
||||
private unowned let context: any ChatLifecycleContext
|
||||
|
||||
init(viewModel: ChatViewModel) {
|
||||
self.viewModel = viewModel
|
||||
init(context: any ChatLifecycleContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func handleDidBecomeActive() {
|
||||
if let bleService = viewModel.meshService as? BLEService {
|
||||
let currentState = bleService.getCurrentBluetoothState()
|
||||
viewModel.updateBluetoothState(currentState)
|
||||
}
|
||||
context.refreshBluetoothState()
|
||||
|
||||
guard let peerID = viewModel.selectedPrivateChatPeer else { return }
|
||||
guard let peerID = context.selectedPrivateChatPeer else { return }
|
||||
|
||||
markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
let viewModel = self.viewModel
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in
|
||||
Task { @MainActor in
|
||||
viewModel?.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
let context = self.context
|
||||
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
|
||||
context?.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
let screenshotMessage = "* \(viewModel.nickname) took a screenshot *"
|
||||
let screenshotMessage = "* \(context.nickname) took a screenshot *"
|
||||
|
||||
if let peerID = viewModel.selectedPrivateChatPeer {
|
||||
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,31 +175,30 @@ final class ChatVerificationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.invalidateEncryptionCache()
|
||||
viewModel.objectWillChange.send()
|
||||
context.invalidateEncryptionCache(for: nil)
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func setupNoiseCallbacks() {
|
||||
let noiseService = viewModel.meshService.getNoiseService()
|
||||
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
context.installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: { [weak self] peerID, fingerprint in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||
|
||||
if self.viewModel.peerIdentityStore.isVerified(fingerprint) {
|
||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||
if self.context.isVerifiedFingerprint(fingerprint) {
|
||||
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||
} else {
|
||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID)
|
||||
self.context.setEncryptionStatus(.noiseSecured, for: peerID)
|
||||
}
|
||||
|
||||
self.viewModel.invalidateEncryptionCache(for: peerID)
|
||||
self.context.invalidateEncryptionCache(for: peerID)
|
||||
|
||||
if self.viewModel.cachedStablePeerID(for: peerID) == nil,
|
||||
let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||
if self.context.cachedStablePeerID(for: peerID) == nil,
|
||||
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: keyData)
|
||||
self.viewModel.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
SecureLogger.debug(
|
||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||
category: .session
|
||||
@@ -90,7 +206,7 @@ final class ChatVerificationCoordinator {
|
||||
}
|
||||
|
||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||
self.viewModel.meshService.sendVerifyChallenge(
|
||||
self.context.sendVerifyChallenge(
|
||||
to: peerID,
|
||||
noiseKeyHex: pending.noiseKeyHex,
|
||||
nonceA: pending.nonceA
|
||||
@@ -100,20 +216,20 @@ final class ChatVerificationCoordinator {
|
||||
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noiseService.onHandshakeRequired = { [weak self] peerID in
|
||||
},
|
||||
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)
|
||||
self.context.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
||||
self.context.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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user