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 }}-
|
${{ runner.os }}-${{ matrix.name }}-
|
||||||
|
|
||||||
- name: Run Tests
|
- 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
|
# SPM tests above only compile the macOS slice; this job covers the
|
||||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var recentInboundEventKeyOrder: [InboundEventKey] = []
|
private var recentInboundEventKeyOrder: [InboundEventKey] = []
|
||||||
private var duplicateInboundEventDropCount = 0
|
private var duplicateInboundEventDropCount = 0
|
||||||
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
|
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
|
||||||
|
private var inboundEventLogCount = 0
|
||||||
// Coalesce duplicate subscribe requests for the same id within a short window.
|
// Coalesce duplicate subscribe requests for the same id within a short window.
|
||||||
private let subscribeCoalesceInterval: TimeInterval = 1.0
|
private let subscribeCoalesceInterval: TimeInterval = 1.0
|
||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
@@ -834,7 +835,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if event.kind != 1059 {
|
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] {
|
if let handler = self.messageHandlers[subId] {
|
||||||
handler(event)
|
handler(event)
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ enum TransportConfig {
|
|||||||
static let nostrInboundEventDedupCap: Int = 4096
|
static let nostrInboundEventDedupCap: Int = 4096
|
||||||
static let nostrInboundEventDedupTrimTarget: Int = 3072
|
static let nostrInboundEventDedupTrimTarget: Int = 3072
|
||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
|
||||||
// UI thresholds
|
// UI thresholds
|
||||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
static let uiLateInsertThreshold: TimeInterval = 15.0
|
||||||
|
|||||||
@@ -1,44 +1,105 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class ChatComposerCoordinator {
|
final class ChatComposerCoordinator {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatComposerContext
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatComposerContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
let peerCandidates = autocompleteCandidates()
|
let peerCandidates = autocompleteCandidates()
|
||||||
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
|
let (suggestions, range) = context.autocompleteQuery(
|
||||||
for: text,
|
for: text,
|
||||||
peers: peerCandidates,
|
peers: peerCandidates,
|
||||||
cursorPosition: cursorPosition
|
cursorPosition: cursorPosition
|
||||||
)
|
)
|
||||||
|
|
||||||
if !suggestions.isEmpty {
|
if !suggestions.isEmpty {
|
||||||
viewModel.autocompleteSuggestions = suggestions
|
context.autocompleteSuggestions = suggestions
|
||||||
viewModel.autocompleteRange = range
|
context.autocompleteRange = range
|
||||||
viewModel.showAutocomplete = true
|
context.showAutocomplete = true
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
} else {
|
} else {
|
||||||
viewModel.autocompleteSuggestions = []
|
context.autocompleteSuggestions = []
|
||||||
viewModel.autocompleteRange = nil
|
context.autocompleteRange = nil
|
||||||
viewModel.showAutocomplete = false
|
context.showAutocomplete = false
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
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
|
context.showAutocomplete = false
|
||||||
viewModel.autocompleteSuggestions = []
|
context.autocompleteSuggestions = []
|
||||||
viewModel.autocompleteRange = nil
|
context.autocompleteRange = nil
|
||||||
viewModel.selectedAutocompleteIndex = 0
|
context.selectedAutocompleteIndex = 0
|
||||||
|
|
||||||
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
||||||
}
|
}
|
||||||
@@ -52,10 +113,10 @@ final class ChatComposerCoordinator {
|
|||||||
range: NSRange(location: 0, length: nsContent.length)
|
range: NSRange(location: 0, length: nsContent.length)
|
||||||
)
|
)
|
||||||
|
|
||||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
let peerNicknames = context.meshPeerNicknames()
|
||||||
var validTokens = Set(peerNicknames.values)
|
var validTokens = Set(peerNicknames.values)
|
||||||
validTokens.insert(viewModel.nickname)
|
validTokens.insert(context.nickname)
|
||||||
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
|
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
for match in matches {
|
for match in matches {
|
||||||
@@ -72,18 +133,18 @@ final class ChatComposerCoordinator {
|
|||||||
|
|
||||||
private extension ChatComposerCoordinator {
|
private extension ChatComposerCoordinator {
|
||||||
func autocompleteCandidates() -> [String] {
|
func autocompleteCandidates() -> [String] {
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
let values = viewModel.meshService.getPeerNicknames().values
|
let values = context.meshPeerNicknames().values
|
||||||
return Array(values.filter { $0 != viewModel.meshService.myNickname })
|
return Array(values.filter { $0 != context.meshNickname })
|
||||||
|
|
||||||
case .location(let channel):
|
case .location(let channel):
|
||||||
var tokens = Set<String>()
|
var tokens = Set<String>()
|
||||||
for (pubkey, nick) in viewModel.geoNicknames {
|
for (pubkey, nick) in context.geoNicknames {
|
||||||
tokens.insert("\(nick)#\(pubkey.suffix(4))")
|
tokens.insert("\(nick)#\(pubkey.suffix(4))")
|
||||||
}
|
}
|
||||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
|
||||||
tokens.remove(myToken)
|
tokens.remove(myToken)
|
||||||
}
|
}
|
||||||
return Array(tokens)
|
return Array(tokens)
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import Foundation
|
|||||||
protocol ChatDeliveryContext: AnyObject {
|
protocol ChatDeliveryContext: AnyObject {
|
||||||
var messages: [BitchatMessage] { get set }
|
var messages: [BitchatMessage] { get set }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
var sentReadReceipts: Set<String> { get set }
|
|
||||||
var isStartupPhase: Bool { get }
|
var isStartupPhase: Bool { get }
|
||||||
|
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
||||||
|
/// Returns the number of receipts removed. (Single mutation path for the
|
||||||
|
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||||
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
/// Confirms receipt so the message router stops retaining the message for resend.
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
||||||
@@ -57,10 +60,7 @@ final class ChatDeliveryCoordinator {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
let oldCount = context.sentReadReceipts.count
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||||
context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
|
|
||||||
|
|
||||||
let removedCount = oldCount - context.sentReadReceipts.count
|
|
||||||
if removedCount > 0 {
|
if removedCount > 0 {
|
||||||
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,143 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class ChatLifecycleCoordinator {
|
protocol ChatLifecycleContext: AnyObject {
|
||||||
private unowned let viewModel: ChatViewModel
|
// MARK: Chat & receipt state
|
||||||
|
var messages: [BitchatMessage] { get }
|
||||||
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
|
var unreadPrivateMessages: Set<PeerID> { get set }
|
||||||
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
|
var sentReadReceipts: Set<String> { get }
|
||||||
|
var nickname: String { get }
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
|
var nostrKeyMapping: [PeerID: String] { get }
|
||||||
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
/// The owner-level read pass (chat manager + receipts); used for the
|
||||||
|
/// delayed re-run after the app becomes active.
|
||||||
|
func markPrivateMessagesAsRead(from peerID: PeerID)
|
||||||
|
/// Marks the chat read in the private chat manager (sends pending mesh READ acks).
|
||||||
|
func markChatAsRead(from peerID: PeerID)
|
||||||
|
/// Schedules main-actor work after a UI-timing delay. Injected so tests
|
||||||
|
/// can run the work synchronously instead of polling wall-clock queues.
|
||||||
|
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
|
||||||
|
func synchronizePrivateConversationStore()
|
||||||
|
func addSystemMessage(_ content: String)
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
// MARK: Peers & sessions
|
||||||
self.viewModel = viewModel
|
func peerNickname(for peerID: PeerID) -> String?
|
||||||
|
/// The peer's current entry in the unified peer service, if known.
|
||||||
|
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
|
||||||
|
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
|
func stopMeshServices()
|
||||||
|
/// Re-reads the transport's current Bluetooth state and updates the alert UI.
|
||||||
|
func refreshBluetoothState()
|
||||||
|
|
||||||
|
// MARK: Routing & receipts
|
||||||
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
|
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
||||||
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
|
|
||||||
|
// MARK: Nostr & geohash
|
||||||
|
var isTeleported: Bool { get }
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
|
func recordGeoParticipant(pubkeyHex: String)
|
||||||
|
|
||||||
|
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
|
||||||
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
|
|
||||||
|
// MARK: Identity persistence
|
||||||
|
/// Forces the identity manager to persist its state now.
|
||||||
|
func forceSaveIdentity()
|
||||||
|
/// Confirms the Noise identity key is still present in the keychain.
|
||||||
|
@discardableResult
|
||||||
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: ChatLifecycleContext {
|
||||||
|
// `messages`, `privateChats`, `unreadPrivateMessages`,
|
||||||
|
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
|
||||||
|
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
|
||||||
|
// `markPrivateMessagesAsRead(from:)`,
|
||||||
|
// `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`,
|
||||||
|
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
||||||
|
// the routing/ack members, `isTeleported`,
|
||||||
|
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
||||||
|
// and `favoriteRelationship(forNoiseKey:)`
|
||||||
|
// are shared requirements with the other contexts or satisfied by
|
||||||
|
// existing `ChatViewModel` members. The members below flatten nested
|
||||||
|
// service accesses into intent-named calls.
|
||||||
|
|
||||||
|
func markChatAsRead(from peerID: PeerID) {
|
||||||
|
privateChatManager.markAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDidBecomeActive() {
|
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
|
||||||
if let bleService = viewModel.meshService as? BLEService {
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
let currentState = bleService.getCurrentBluetoothState()
|
|
||||||
viewModel.updateBluetoothState(currentState)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let peerID = viewModel.selectedPrivateChatPeer else { return }
|
|
||||||
|
|
||||||
markPrivateMessagesAsRead(from: peerID)
|
|
||||||
|
|
||||||
let viewModel = self.viewModel
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
viewModel?.markPrivateMessagesAsRead(from: peerID)
|
work()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleScreenshotCaptured() {
|
func stopMeshServices() {
|
||||||
let screenshotMessage = "* \(viewModel.nickname) took a screenshot *"
|
meshService.stopServices()
|
||||||
|
}
|
||||||
|
|
||||||
if let peerID = viewModel.selectedPrivateChatPeer {
|
func refreshBluetoothState() {
|
||||||
|
if let bleService = meshService as? BLEService {
|
||||||
|
updateBluetoothState(bleService.getCurrentBluetoothState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceSaveIdentity() {
|
||||||
|
identityManager.forceSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
keychain.verifyIdentityKeyExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatLifecycleCoordinator {
|
||||||
|
private unowned let context: any ChatLifecycleContext
|
||||||
|
|
||||||
|
init(context: any ChatLifecycleContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDidBecomeActive() {
|
||||||
|
context.refreshBluetoothState()
|
||||||
|
|
||||||
|
guard let peerID = context.selectedPrivateChatPeer else { return }
|
||||||
|
|
||||||
|
markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
|
let context = self.context
|
||||||
|
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
|
||||||
|
context?.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScreenshotCaptured() {
|
||||||
|
let screenshotMessage = "* \(context.nickname) took a screenshot *"
|
||||||
|
|
||||||
|
if let peerID = context.selectedPrivateChatPeer {
|
||||||
sendPrivateScreenshotNotificationIfPossible(
|
sendPrivateScreenshotNotificationIfPossible(
|
||||||
screenshotMessage,
|
screenshotMessage,
|
||||||
to: peerID
|
to: peerID
|
||||||
@@ -40,9 +147,9 @@ final class ChatLifecycleCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
viewModel.meshService.sendMessage(
|
context.sendMeshMessage(
|
||||||
screenshotMessage,
|
screenshotMessage,
|
||||||
mentions: [],
|
mentions: [],
|
||||||
messageID: UUID().uuidString,
|
messageID: UUID().uuidString,
|
||||||
@@ -56,43 +163,41 @@ final class ChatLifecycleCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.addSystemMessage("you took a screenshot")
|
context.addSystemMessage("you took a screenshot")
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveIdentityState() {
|
func saveIdentityState() {
|
||||||
viewModel.identityManager.forceSave()
|
context.forceSaveIdentity()
|
||||||
_ = viewModel.keychain.verifyIdentityKeyExists()
|
context.verifyIdentityKeyExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate() {
|
func applicationWillTerminate() {
|
||||||
viewModel.meshService.stopServices()
|
context.stopMeshServices()
|
||||||
saveIdentityState()
|
saveIdentityState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
viewModel.privateChatManager.markAsRead(from: peerID)
|
context.markChatAsRead(from: peerID)
|
||||||
viewModel.synchronizePrivateConversationStore()
|
context.synchronizePrivateConversationStore()
|
||||||
|
|
||||||
if peerID.isGeoDM,
|
if peerID.isGeoDM,
|
||||||
let recipientHex = viewModel.nostrKeyMapping[peerID],
|
let recipientHex = context.nostrKeyMapping[peerID],
|
||||||
case .location(let channel) = viewModel.activeChannel,
|
case .location(let channel) = context.activeChannel,
|
||||||
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let messages = viewModel.privateChats[peerID] ?? []
|
let messages = context.privateChats[peerID] ?? []
|
||||||
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
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(
|
SecureLogger.debug(
|
||||||
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…",
|
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
context.sendGeohashReadReceipt(
|
||||||
nostrTransport.senderPeerID = viewModel.meshService.myPeerID
|
|
||||||
nostrTransport.sendReadReceiptGeohash(
|
|
||||||
message.id,
|
message.id,
|
||||||
toRecipientHex: recipientHex,
|
toRecipientHex: recipientHex,
|
||||||
from: identity
|
from: identity
|
||||||
)
|
)
|
||||||
viewModel.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,16 +206,16 @@ final class ChatLifecycleCoordinator {
|
|||||||
var peerNostrPubkey: String?
|
var peerNostrPubkey: String?
|
||||||
|
|
||||||
if let noiseKey = Data(hexString: peerID.id),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
|
||||||
noiseKeyHex = peerID
|
noiseKeyHex = peerID
|
||||||
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
|
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)
|
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
|
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
|
||||||
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
||||||
|
|
||||||
if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) {
|
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
|
||||||
viewModel.unreadPrivateMessages.remove(noiseKeyHex)
|
context.unreadPrivateMessages.remove(noiseKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,37 +226,37 @@ final class ChatLifecycleCoordinator {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
|
guard !context.sentReadReceipts.contains(message.id) else { continue }
|
||||||
|
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: viewModel.meshService.myPeerID,
|
readerID: context.myPeerID,
|
||||||
readerNickname: viewModel.nickname
|
readerNickname: context.nickname
|
||||||
)
|
)
|
||||||
let recipientPeerID = peerID.isHex
|
let recipientPeerID = peerID.isHex
|
||||||
? peerID
|
? peerID
|
||||||
: (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
|
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
|
||||||
|
|
||||||
viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID)
|
context.routeReadReceipt(receipt, to: recipientPeerID)
|
||||||
viewModel.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
guard let peerID else { return viewModel.messages }
|
guard let peerID else { return context.messages }
|
||||||
return getPrivateChatMessages(for: peerID)
|
return getPrivateChatMessages(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
var combined: [BitchatMessage] = []
|
var combined: [BitchatMessage] = []
|
||||||
|
|
||||||
if let ephemeralMessages = viewModel.privateChats[peerID] {
|
if let ephemeralMessages = context.privateChats[peerID] {
|
||||||
combined.append(contentsOf: ephemeralMessages)
|
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)
|
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)
|
combined.append(contentsOf: stableMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,12 +280,12 @@ final class ChatLifecycleCoordinator {
|
|||||||
|
|
||||||
private extension ChatLifecycleCoordinator {
|
private extension ChatLifecycleCoordinator {
|
||||||
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
|
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 {
|
switch sessionState {
|
||||||
case .established:
|
case .established:
|
||||||
viewModel.messageRouter.sendPrivate(
|
context.routePrivateMessage(
|
||||||
message,
|
message,
|
||||||
to: peerID,
|
to: peerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
@@ -203,30 +308,30 @@ private extension ChatLifecycleCoordinator {
|
|||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: viewModel.meshService.peerNickname(peerID: peerID),
|
recipientNickname: context.peerNickname(for: peerID),
|
||||||
senderPeerID: viewModel.meshService.myPeerID
|
senderPeerID: context.myPeerID
|
||||||
)
|
)
|
||||||
|
|
||||||
var chats = viewModel.privateChats
|
var chats = context.privateChats
|
||||||
if chats[peerID] == nil {
|
if chats[peerID] == nil {
|
||||||
chats[peerID] = []
|
chats[peerID] = []
|
||||||
}
|
}
|
||||||
chats[peerID]?.append(notice)
|
chats[peerID]?.append(notice)
|
||||||
viewModel.privateChats = chats
|
context.privateChats = chats
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let viewModel else { return }
|
guard let context else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: message,
|
content: message,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: viewModel.nickname,
|
nickname: context.nickname,
|
||||||
teleported: viewModel.locationManager.teleported
|
teleported: context.isTeleported
|
||||||
)
|
)
|
||||||
|
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||||
@@ -236,10 +341,10 @@ private extension ChatLifecycleCoordinator {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
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")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,90 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#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
|
@MainActor
|
||||||
final class ChatMediaTransferCoordinator {
|
final class ChatMediaTransferCoordinator {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatMediaTransferContext
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatMediaTransferContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceNote(at url: URL) {
|
func sendVoiceNote(at url: URL) {
|
||||||
guard viewModel.canSendMediaInCurrentContext else {
|
guard context.canSendMediaInCurrentContext else {
|
||||||
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = viewModel.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
let message = enqueueMediaMessage(
|
let message = enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -41,9 +105,9 @@ final class ChatMediaTransferCoordinator {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
} else {
|
} else {
|
||||||
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||||
@@ -96,20 +160,20 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
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)
|
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
||||||
cleanup?()
|
cleanup?()
|
||||||
viewModel.addSystemMessage("Images are only available in mesh chats.")
|
context.addSystemMessage("Images are only available in mesh chats.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = viewModel.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,22 +191,22 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||||
} else {
|
} else {
|
||||||
self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId)
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.viewModel.addSystemMessage("Image is too large to send.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
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 {
|
if let peerID = targetPeer {
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
sender: viewModel.nickname,
|
sender: context.nickname,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: viewModel.nicknameForPeer(peerID),
|
recipientNickname: context.nicknameForPeer(peerID),
|
||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: context.myPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
var chats = viewModel.privateChats
|
var chats = context.privateChats
|
||||||
chats[peerID, default: []].append(message)
|
chats[peerID, default: []].append(message)
|
||||||
viewModel.privateChats = chats
|
context.privateChats = chats
|
||||||
viewModel.trimMessagesIfNeeded()
|
context.trimMessagesIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
let (displayName, senderPeerID) = viewModel.currentPublicSender()
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
sender: displayName,
|
sender: displayName,
|
||||||
content: content,
|
content: content,
|
||||||
@@ -181,14 +245,14 @@ final class ChatMediaTransferCoordinator {
|
|||||||
senderPeerID: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
|
context.appendTimelineMessage(message, to: context.activeChannel)
|
||||||
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
|
context.refreshVisibleMessages(from: context.activeChannel)
|
||||||
viewModel.trimMessagesIfNeeded()
|
context.trimMessagesIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = viewModel.deduplicationService.normalizedContentKey(message.content)
|
let key = context.normalizedContentKey(message.content)
|
||||||
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
|
context.recordContentKey(key, timestamp: timestamp)
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +281,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleMediaSendFailure(messageID: String, reason: String) {
|
func handleMediaSendFailure(messageID: String, reason: String) {
|
||||||
viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,18 +289,18 @@ final class ChatMediaTransferCoordinator {
|
|||||||
switch event {
|
switch event {
|
||||||
case .started(let id, let total):
|
case .started(let id, let total):
|
||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
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):
|
case .updated(let id, let sent, let total):
|
||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
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, _):
|
case .completed(let id, _):
|
||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
viewModel.updateMessageDeliveryStatus(messageID, status: .sent)
|
context.updateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
case .cancelled(let id, _, _):
|
case .cancelled(let id, _, _):
|
||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
clearTransferMapping(for: messageID)
|
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],
|
if let transferId = messageIDToTransferId[messageID],
|
||||||
let active = transferIdToMessageIDs[transferId]?.first,
|
let active = transferIdToMessageIDs[transferId]?.first,
|
||||||
active == messageID {
|
active == messageID {
|
||||||
viewModel.meshService.cancelTransfer(transferId)
|
context.cancelTransfer(transferId)
|
||||||
}
|
}
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
viewModel.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteMediaMessage(messageID: String) {
|
func deleteMediaMessage(messageID: String) {
|
||||||
clearTransferMapping(for: messageID)
|
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 BitLogger
|
||||||
import Foundation
|
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
|
@MainActor
|
||||||
final class ChatOutgoingCoordinator {
|
final class ChatOutgoingCoordinator {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatOutgoingContext
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatOutgoingContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(_ content: String) {
|
func sendMessage(_ content: String) {
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
|
|
||||||
if content.hasPrefix("/") {
|
if content.hasPrefix("/") {
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
viewModel?.handleCommand(content)
|
context?.handleCommand(content)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if context.selectedPrivateChatPeer != nil {
|
||||||
viewModel.updatePrivateChatPeerIfNeeded()
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
|
|
||||||
if let selectedPeer = viewModel.selectedPrivateChatPeer {
|
if let selectedPeer = context.selectedPrivateChatPeer {
|
||||||
viewModel.sendPrivateMessage(content, to: selectedPeer)
|
context.sendPrivateMessage(content, to: selectedPeer)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mentions = viewModel.parseMentions(from: content)
|
let mentions = context.parseMentions(from: content)
|
||||||
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||||
guard let preparedMessage else { return }
|
guard let preparedMessage else { return }
|
||||||
|
|
||||||
@@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator {
|
|||||||
mentions: [String]
|
mentions: [String]
|
||||||
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
||||||
var geoContext: ChatViewModel.GeoOutgoingContext?
|
var geoContext: ChatViewModel.GeoOutgoingContext?
|
||||||
var displaySender = viewModel.nickname
|
var displaySender = context.nickname
|
||||||
var localSenderPeerID = viewModel.meshService.myPeerID
|
var localSenderPeerID = context.myPeerID
|
||||||
var messageID: String?
|
var messageID: String?
|
||||||
var messageTimestamp = Date()
|
var messageTimestamp = Date()
|
||||||
|
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
break
|
break
|
||||||
|
|
||||||
case .location(let channel):
|
case .location(let channel):
|
||||||
do {
|
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))
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||||
displaySender = viewModel.nickname + "#" + suffix
|
displaySender = context.nickname + "#" + suffix
|
||||||
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
|
|
||||||
let teleported = viewModel.locationManager.teleported
|
let teleported = context.isTeleported
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: channel.geohash,
|
geohash: channel.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: viewModel.nickname,
|
nickname: context.nickname,
|
||||||
teleported: teleported
|
teleported: teleported
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
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")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
@@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendLocalEcho(_ message: BitchatMessage) {
|
func appendLocalEcho(_ message: BitchatMessage) {
|
||||||
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
|
context.appendTimelineMessage(message, to: context.activeChannel)
|
||||||
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
|
context.refreshVisibleMessages(from: context.activeChannel)
|
||||||
|
|
||||||
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
|
let contentKey = context.normalizedContentKey(message.content)
|
||||||
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
|
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||||
viewModel.trimMessagesIfNeeded()
|
context.trimMessagesIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func routePublicMessage(
|
func routePublicMessage(
|
||||||
@@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator {
|
|||||||
messageID: String,
|
messageID: String,
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
) {
|
) {
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
viewModel.lastPublicActivityAt["mesh"] = Date()
|
context.recordPublicActivity(forChannelKey: "mesh")
|
||||||
viewModel.meshService.sendMessage(
|
context.sendMeshMessage(
|
||||||
originalContent,
|
originalContent,
|
||||||
mentions: mentions,
|
mentions: mentions,
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
@@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .location(let channel):
|
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 {
|
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
|
||||||
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
|
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")
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
viewModel?.sendGeohash(context: geoContext)
|
context?.sendGeohash(context: geoContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,236 @@ import BitLogger
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ChatPeerIdentityCoordinator {
|
/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner.
|
||||||
private unowned let viewModel: ChatViewModel
|
///
|
||||||
|
/// 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) {
|
// MARK: Private chat session lifecycle
|
||||||
self.viewModel = viewModel
|
/// 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
|
@MainActor
|
||||||
func openMostRelevantPrivateChat() {
|
func openMostRelevantPrivateChat() {
|
||||||
let unreadSorted = viewModel.unreadPrivateMessages
|
let unreadSorted = context.unreadPrivateMessages
|
||||||
.map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
|
.map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
|
||||||
.sorted { $0.1 > $1.1 }
|
.sorted { $0.1 > $1.1 }
|
||||||
if let target = unreadSorted.first?.0 {
|
if let target = unreadSorted.first?.0 {
|
||||||
startPrivateChat(with: target)
|
startPrivateChat(with: target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let recent = viewModel.privateChats
|
let recent = context.privateChats
|
||||||
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
|
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
|
||||||
.sorted { $0.ts > $1.ts }
|
.sorted { $0.ts > $1.ts }
|
||||||
if let target = recent.first?.id {
|
if let target = recent.first?.id {
|
||||||
@@ -30,7 +242,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func isPeerBlocked(_ peerID: PeerID) -> Bool {
|
func isPeerBlocked(_ peerID: PeerID) -> Bool {
|
||||||
viewModel.unifiedPeerService.isBlocked(peerID)
|
context.unifiedIsBlocked(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -38,24 +250,24 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
var noiseKeyPeerID: PeerID?
|
var noiseKeyPeerID: PeerID?
|
||||||
var nostrPeerID: PeerID?
|
var nostrPeerID: PeerID?
|
||||||
|
|
||||||
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
|
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
|
||||||
if let nostrHex = peer.nostrPublicKey {
|
if let nostrHex = peer.nostrPublicKey {
|
||||||
nostrPeerID = PeerID(nostr_: nostrHex)
|
nostrPeerID = PeerID(nostr_: nostrHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = ChatUnreadPeerContext(
|
let unreadContext = ChatUnreadPeerContext(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
noiseKeyPeerID: noiseKeyPeerID,
|
noiseKeyPeerID: noiseKeyPeerID,
|
||||||
nostrPeerID: nostrPeerID,
|
nostrPeerID: nostrPeerID,
|
||||||
nickname: viewModel.meshService.peerNickname(peerID: peerID)
|
nickname: context.peerNickname(for: peerID)
|
||||||
)
|
)
|
||||||
|
|
||||||
return ChatUnreadStateResolver.hasUnreadMessages(
|
return ChatUnreadStateResolver.hasUnreadMessages(
|
||||||
for: context,
|
for: unreadContext,
|
||||||
unreadPrivateMessages: viewModel.unreadPrivateMessages,
|
unreadPrivateMessages: context.unreadPrivateMessages,
|
||||||
privateChats: viewModel.privateChats
|
privateChats: context.privateChats
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,46 +278,46 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.unifiedPeerService.toggleFavorite(peerID)
|
context.unifiedToggleFavorite(peerID)
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func isFavorite(peerID: PeerID) -> Bool {
|
func isFavorite(peerID: PeerID) -> Bool {
|
||||||
if let noisePublicKey = peerID.noiseKey {
|
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
|
@MainActor
|
||||||
func updatePrivateChatPeerIfNeeded() {
|
func updatePrivateChatPeerIfNeeded() {
|
||||||
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
|
guard let chatFingerprint = context.selectedPrivateChatFingerprint,
|
||||||
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
|
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID {
|
if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID {
|
||||||
migrateChatState(from: oldPeerID, to: currentPeerID)
|
migrateChatState(from: oldPeerID, to: currentPeerID)
|
||||||
viewModel.selectedPrivateChatPeer = currentPeerID
|
context.selectedPrivateChatPeer = currentPeerID
|
||||||
} else if viewModel.selectedPrivateChatPeer == nil {
|
} else if context.selectedPrivateChatPeer == nil {
|
||||||
viewModel.selectedPrivateChatPeer = currentPeerID
|
context.selectedPrivateChatPeer = currentPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
var unread = viewModel.unreadPrivateMessages
|
var unread = context.unreadPrivateMessages
|
||||||
unread.remove(currentPeerID)
|
unread.remove(currentPeerID)
|
||||||
viewModel.unreadPrivateMessages = unread
|
context.unreadPrivateMessages = unread
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func startPrivateChat(with peerID: PeerID) {
|
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) {
|
if context.unifiedIsBlocked(peerID) {
|
||||||
viewModel.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(
|
String(
|
||||||
format: String(
|
format: String(
|
||||||
localized: "system.chat.blocked",
|
localized: "system.chat.blocked",
|
||||||
@@ -118,9 +330,9 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
|
if let peer = context.unifiedPeer(for: peerID),
|
||||||
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
||||||
viewModel.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(
|
String(
|
||||||
format: String(
|
format: String(
|
||||||
localized: "system.chat.requires_favorite",
|
localized: "system.chat.requires_favorite",
|
||||||
@@ -133,16 +345,12 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = viewModel.privateChatManager.consolidateMessages(
|
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
|
||||||
for: peerID,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
persistedReadReceipts: viewModel.sentReadReceipts
|
|
||||||
)
|
|
||||||
|
|
||||||
if !peerID.isGeoDM && !peerID.isGeoChat {
|
if !peerID.isGeoDM && !peerID.isGeoChat {
|
||||||
switch viewModel.meshService.getNoiseSessionState(for: peerID) {
|
switch context.noiseSessionState(for: peerID) {
|
||||||
case .none, .failed:
|
case .none, .failed:
|
||||||
viewModel.meshService.triggerHandshake(with: peerID)
|
context.triggerHandshake(with: peerID)
|
||||||
case .handshakeQueued, .handshaking, .established:
|
case .handshakeQueued, .handshaking, .established:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -150,28 +358,24 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.privateChatManager.syncReadReceiptsForSentMessages(
|
context.syncReadReceiptsForSentMessages(for: peerID)
|
||||||
peerID: peerID,
|
|
||||||
nickname: viewModel.nickname,
|
|
||||||
externalReceipts: &viewModel.sentReadReceipts
|
|
||||||
)
|
|
||||||
|
|
||||||
if let fingerprint = getFingerprint(for: peerID) {
|
if let fingerprint = getFingerprint(for: peerID) {
|
||||||
viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID)
|
context.setStoredFingerprint(fingerprint, for: peerID)
|
||||||
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
|
context.selectedPrivateChatFingerprint = fingerprint
|
||||||
} else {
|
} else {
|
||||||
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
|
context.selectedPrivateChatFingerprint = nil
|
||||||
}
|
}
|
||||||
viewModel.privateChatManager.startChat(with: peerID)
|
context.beginPrivateChatSession(with: peerID)
|
||||||
viewModel.synchronizePrivateConversationStore()
|
context.synchronizePrivateConversationStore()
|
||||||
viewModel.synchronizeConversationSelectionStore()
|
context.synchronizeConversationSelectionStore()
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
context.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func endPrivateChat() {
|
func endPrivateChat() {
|
||||||
viewModel.selectedPrivateChatPeer = nil
|
context.selectedPrivateChatPeer = nil
|
||||||
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
|
context.selectedPrivateChatFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -182,8 +386,8 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
func handleFavoriteStatusChanged(_ notification: Notification) {
|
func handleFavoriteStatusChanged(_ notification: Notification) {
|
||||||
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
|
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
|
||||||
|
|
||||||
Task { @MainActor [weak viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let viewModel else { return }
|
guard let context else { return }
|
||||||
|
|
||||||
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
|
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
|
||||||
isKeyUpdate,
|
isKeyUpdate,
|
||||||
@@ -200,28 +404,26 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
let peerID = PeerID(hexData: peerPublicKey)
|
let peerID = PeerID(hexData: peerPublicKey)
|
||||||
let action = isFavorite ? "favorited" : "unfavorited"
|
let action = isFavorite ? "favorited" : "unfavorited"
|
||||||
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
|
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
|
||||||
viewModel.addSystemMessage("\(peerNickname) \(action) you")
|
context.addSystemMessage("\(peerNickname) \(action) you")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateEncryptionStatusForPeers() {
|
func updateEncryptionStatusForPeers() {
|
||||||
for peerID in viewModel.connectedPeers {
|
for peerID in context.connectedPeers {
|
||||||
updateEncryptionStatus(for: peerID)
|
updateEncryptionStatus(for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateEncryptionStatus(for peerID: PeerID) {
|
func updateEncryptionStatus(for peerID: PeerID) {
|
||||||
let noiseService = viewModel.meshService.getNoiseService()
|
if context.hasEstablishedNoiseSession(with: peerID) {
|
||||||
|
context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
} else if context.hasNoiseSession(with: peerID) {
|
||||||
viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
|
context.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
||||||
} else if noiseService.hasSession(with: peerID) {
|
|
||||||
viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
|
||||||
} else {
|
} else {
|
||||||
viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID)
|
context.setEncryptionStatus(nil, for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateEncryptionCache(for: peerID)
|
invalidateEncryptionCache(for: peerID)
|
||||||
@@ -229,12 +431,12 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||||||
if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) {
|
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
|
||||||
return cachedStatus
|
return cachedStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
||||||
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
|
let sessionState = context.noiseSessionState(for: peerID)
|
||||||
|
|
||||||
let status: EncryptionStatus
|
let status: EncryptionStatus
|
||||||
switch sessionState {
|
switch sessionState {
|
||||||
@@ -248,18 +450,18 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
|
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
|
context.setCachedEncryptionStatus(status, for: peerID)
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||||
viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID)
|
context.invalidateStoredEncryptionCache(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getFingerprint(for peerID: PeerID) -> String? {
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
viewModel.unifiedPeerService.getFingerprint(for: peerID)
|
context.unifiedFingerprint(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -270,12 +472,12 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
return peerID.id
|
return peerID.id
|
||||||
}
|
}
|
||||||
|
|
||||||
if let nickname = viewModel.meshService.getPeerNicknames()[peerID] {
|
if let nickname = context.meshPeerNicknames()[peerID] {
|
||||||
return nickname
|
return nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
if let fingerprint = getFingerprint(for: peerID),
|
||||||
let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
let identity = context.socialIdentity(forFingerprint: fingerprint) {
|
||||||
if let petname = identity.localPetname {
|
if let petname = identity.localPetname {
|
||||||
return petname
|
return petname
|
||||||
}
|
}
|
||||||
@@ -289,19 +491,18 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getMyFingerprint() -> String {
|
func getMyFingerprint() -> String {
|
||||||
viewModel.meshService.getNoiseService().getIdentityFingerprint()
|
context.noiseIdentityFingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||||
switch viewModel.activeChannel {
|
switch context.activeChannel {
|
||||||
case .location:
|
case .location:
|
||||||
if nickname.contains("#"),
|
if nickname.contains("#"),
|
||||||
let person = viewModel.publicConversationCoordinator
|
let person = context.visibleGeohashPeople()
|
||||||
.visibleGeohashPeople()
|
|
||||||
.first(where: { $0.displayName == nickname }) {
|
.first(where: { $0.displayName == nickname }) {
|
||||||
let conversationKey = PeerID(nostr_: person.id)
|
let conversationKey = PeerID(nostr_: person.id)
|
||||||
viewModel.nostrKeyMapping[conversationKey] = person.id
|
context.registerNostrKeyMapping(person.id, for: conversationKey)
|
||||||
return conversationKey
|
return conversationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +511,9 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
.first
|
.first
|
||||||
.map(String.init)?
|
.map(String.init)?
|
||||||
.lowercased() ?? nickname.lowercased()
|
.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)
|
let conversationKey = PeerID(nostr_: pubkey)
|
||||||
viewModel.nostrKeyMapping[conversationKey] = pubkey
|
context.registerNostrKeyMapping(pubkey, for: conversationKey)
|
||||||
return conversationKey
|
return conversationKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,20 +521,20 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewModel.unifiedPeerService.getPeerID(for: nickname)
|
return context.unifiedPeerID(forNickname: nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func nicknameForPeer(_ peerID: PeerID) -> String {
|
func nicknameForPeer(_ peerID: PeerID) -> String {
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
if let name = context.peerNickname(for: peerID) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let favorite = context.favoriteRelationship(forPeerID: peerID),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
}
|
}
|
||||||
if let noiseKey = Data(hexString: peerID.id),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
}
|
}
|
||||||
@@ -344,7 +545,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
private extension ChatPeerIdentityCoordinator {
|
private extension ChatPeerIdentityCoordinator {
|
||||||
@MainActor
|
@MainActor
|
||||||
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
|
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 peerID
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -352,8 +553,8 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
||||||
if let oldMessages = viewModel.privateChats[oldPeerID] {
|
if let oldMessages = context.privateChats[oldPeerID] {
|
||||||
var chats = viewModel.privateChats
|
var chats = context.privateChats
|
||||||
chats[newPeerID, default: []].append(contentsOf: oldMessages)
|
chats[newPeerID, default: []].append(contentsOf: oldMessages)
|
||||||
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
|
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
|
||||||
|
|
||||||
@@ -367,48 +568,48 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chats.removeValue(forKey: oldPeerID)
|
chats.removeValue(forKey: oldPeerID)
|
||||||
viewModel.privateChats = chats
|
context.privateChats = chats
|
||||||
}
|
}
|
||||||
|
|
||||||
var unread = viewModel.unreadPrivateMessages
|
var unread = context.unreadPrivateMessages
|
||||||
if unread.contains(oldPeerID) {
|
if unread.contains(oldPeerID) {
|
||||||
unread.remove(oldPeerID)
|
unread.remove(oldPeerID)
|
||||||
unread.insert(newPeerID)
|
unread.insert(newPeerID)
|
||||||
viewModel.unreadPrivateMessages = unread
|
context.unreadPrivateMessages = unread
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
|
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)
|
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)
|
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrateChatState(from: oldPeerID, to: newPeerID)
|
migrateChatState(from: oldPeerID, to: newPeerID)
|
||||||
|
|
||||||
if viewModel.selectedPrivateChatPeer == oldPeerID {
|
if context.selectedPrivateChatPeer == oldPeerID {
|
||||||
viewModel.selectedPrivateChatPeer = newPeerID
|
context.selectedPrivateChatPeer = newPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping(
|
if let fingerprint = context.migrateFingerprintMapping(
|
||||||
from: oldPeerID,
|
from: oldPeerID,
|
||||||
to: newPeerID,
|
to: newPeerID,
|
||||||
fallback: getFingerprint(for: newPeerID)
|
fallback: getFingerprint(for: newPeerID)
|
||||||
) {
|
) {
|
||||||
if viewModel.selectedPrivateChatPeer == newPeerID {
|
if context.selectedPrivateChatPeer == newPeerID {
|
||||||
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
|
context.selectedPrivateChatFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
|
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
|
return nickname
|
||||||
}
|
}
|
||||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
|
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@@ -417,7 +618,7 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
if let fingerprint = getFingerprint(for: peerID),
|
||||||
viewModel.peerIdentityStore.isVerified(fingerprint) {
|
context.isVerifiedFingerprint(fingerprint) {
|
||||||
return .noiseVerified
|
return .noiseVerified
|
||||||
}
|
}
|
||||||
return .noiseSecured
|
return .noiseSecured
|
||||||
@@ -425,36 +626,36 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
|
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
|
||||||
if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID {
|
if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) {
|
||||||
viewModel.unifiedPeerService.toggleFavorite(ephemeralID)
|
context.unifiedToggleFavorite(ephemeralID)
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
|
||||||
let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
|
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
|
||||||
let plan = ChatFavoriteTogglePolicy.plan(
|
let plan = ChatFavoriteTogglePolicy.plan(
|
||||||
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
||||||
fallbackNickname: fallbackNickname,
|
fallbackNickname: fallbackNickname,
|
||||||
bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey)
|
bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey)
|
||||||
)
|
)
|
||||||
|
|
||||||
switch plan.persistenceAction {
|
switch plan.persistenceAction {
|
||||||
case .add(let nickname, let nostrKey):
|
case .add(let nickname, let nostrKey):
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
context.addFavorite(
|
||||||
peerNoisePublicKey: noisePublicKey,
|
noiseKey: noisePublicKey,
|
||||||
peerNostrPublicKey: nostrKey,
|
nostrPublicKey: nostrKey,
|
||||||
peerNickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
|
|
||||||
case .remove:
|
case .remove:
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
context.removeFavorite(noiseKey: noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
|
|
||||||
if case .send(let isFavorite) = plan.notification {
|
if case .send(let isFavorite) = plan.notification {
|
||||||
viewModel.sendFavoriteNotificationViaNostr(
|
context.sendFavoriteNotificationViaNostr(
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
isFavorite: isFavorite
|
isFavorite: isFavorite
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,72 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
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 {
|
final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatPeerListContext
|
||||||
private var recentlySeenPeers: Set<PeerID> = []
|
private var recentlySeenPeers: Set<PeerID> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
private var lastNetworkNotificationTime = Date.distantPast
|
||||||
private var networkResetTimer: Timer?
|
private var networkResetTimer: Timer?
|
||||||
private var networkEmptyTimer: Timer?
|
private var networkEmptyTimer: Timer?
|
||||||
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
|
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatPeerListContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -29,23 +85,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
|||||||
private extension ChatPeerListCoordinator {
|
private extension ChatPeerListCoordinator {
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePeerListUpdate(_ peers: [PeerID]) {
|
func handlePeerListUpdate(_ peers: [PeerID]) {
|
||||||
viewModel.isConnected = !peers.isEmpty
|
context.isConnected = !peers.isEmpty
|
||||||
cleanupStaleUnreadPeerIDs()
|
cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
let meshPeers = peers.filter { peerID in
|
let meshPeers = peers.filter { peerID in
|
||||||
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
|
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNetworkAvailability(meshPeers)
|
handleNetworkAvailability(meshPeers)
|
||||||
|
|
||||||
for peerID in peers {
|
for peerID in peers {
|
||||||
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.updateEncryptionStatusForPeers()
|
context.updateEncryptionStatusForPeers()
|
||||||
|
|
||||||
if viewModel.hasTrackedPrivateChatSelection {
|
if context.hasTrackedPrivateChatSelection {
|
||||||
viewModel.updatePrivateChatPeerIfNeeded()
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +123,7 @@ private extension ChatPeerListCoordinator {
|
|||||||
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
|
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
|
||||||
recentlySeenPeers.formUnion(newPeers)
|
recentlySeenPeers.formUnion(newPeers)
|
||||||
lastNetworkNotificationTime = Date()
|
lastNetworkNotificationTime = Date()
|
||||||
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
|
context.notifyNetworkAvailable(peerCount: meshPeers.count)
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||||
category: .session
|
category: .session
|
||||||
@@ -79,34 +135,34 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cleanupStaleUnreadPeerIDs() {
|
func cleanupStaleUnreadPeerIDs() {
|
||||||
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
|
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
|
||||||
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
|
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
|
||||||
|
|
||||||
guard !staleIDs.isEmpty else {
|
guard !staleIDs.isEmpty else {
|
||||||
viewModel.cleanupOldReadReceipts()
|
context.cleanupOldReadReceipts()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var idsToRemove: [PeerID] = []
|
var idsToRemove: [PeerID] = []
|
||||||
|
|
||||||
for staleID in staleIDs {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
|
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
idsToRemove.append(staleID)
|
idsToRemove.append(staleID)
|
||||||
viewModel.unreadPrivateMessages.remove(staleID)
|
context.unreadPrivateMessages.remove(staleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !idsToRemove.isEmpty {
|
if !idsToRemove.isEmpty {
|
||||||
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.cleanupOldReadReceipts()
|
context.cleanupOldReadReceipts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -121,18 +177,14 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNetworkResetTimerFired() {
|
func handleNetworkResetTimerFired() {
|
||||||
let activeMeshPeers = viewModel.meshService
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
.currentPeerSnapshots()
|
|
||||||
.filter { snapshot in
|
|
||||||
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if activeMeshPeers.isEmpty {
|
if activeMeshPeerCount == 0 {
|
||||||
recentlySeenPeers.removeAll()
|
recentlySeenPeers.removeAll()
|
||||||
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
|
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,18 +217,14 @@ private extension ChatPeerListCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNetworkEmptyTimerFired() {
|
func handleNetworkEmptyTimerFired() {
|
||||||
let activeMeshPeers = viewModel.meshService
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
.currentPeerSnapshots()
|
|
||||||
.filter { snapshot in
|
|
||||||
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if activeMeshPeers.isEmpty {
|
if activeMeshPeerCount == 0 {
|
||||||
recentlySeenPeers.removeAll()
|
recentlySeenPeers.removeAll()
|
||||||
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
|
"⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,23 @@ import Foundation
|
|||||||
protocol ChatPrivateConversationContext: AnyObject {
|
protocol ChatPrivateConversationContext: AnyObject {
|
||||||
// MARK: Conversation state
|
// MARK: Conversation state
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
var sentReadReceipts: Set<String> { get set }
|
var sentReadReceipts: Set<String> { get }
|
||||||
var sentGeoDeliveryAcks: Set<String> { get set }
|
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
var unreadPrivateMessages: Set<PeerID> { get set }
|
||||||
var selectedPrivateChatPeer: PeerID? { get set }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var activeChannel: ChannelID { get }
|
var activeChannel: ChannelID { get }
|
||||||
var nostrKeyMapping: [PeerID: String] { get }
|
var nostrKeyMapping: [PeerID: String] { get }
|
||||||
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@discardableResult
|
||||||
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
|
||||||
|
/// Moves the open private chat to `newPeerID` when the current selection is
|
||||||
|
/// one of the peer IDs being migrated away.
|
||||||
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
|
|
||||||
@@ -59,11 +69,21 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
func addMeshOnlySystemMessage(_ content: String)
|
func addMeshOnlySystemMessage(_ content: String)
|
||||||
func sanitizeChat(for peerID: PeerID)
|
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 {
|
extension ChatViewModel: ChatPrivateConversationContext {
|
||||||
// `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared
|
// `privateChats` and `notifyUIChanged()` are shared requirements with
|
||||||
// requirements with `ChatDeliveryContext`; the remaining state members are
|
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
|
||||||
|
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
|
||||||
|
// backing state in `ChatViewModel`. The remaining state members are
|
||||||
// satisfied by existing `ChatViewModel` properties and methods.
|
// satisfied by existing `ChatViewModel` properties and methods.
|
||||||
|
|
||||||
var myPeerID: PeerID { meshService.myPeerID }
|
var myPeerID: PeerID { meshService.myPeerID }
|
||||||
@@ -149,6 +169,23 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
privateChatManager.sanitizeChat(for: peerID)
|
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 {
|
private func makeGeohashNostrTransport() -> NostrTransport {
|
||||||
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
transport.senderPeerID = meshService.myPeerID
|
transport.senderPeerID = meshService.myPeerID
|
||||||
@@ -187,7 +224,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||||
let isConnected = context.isPeerConnected(peerID)
|
let isConnected = context.isPeerConnected(peerID)
|
||||||
let isReachable = context.isPeerReachable(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 isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
|
|
||||||
@@ -381,11 +418,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isViewing && shouldMarkUnread {
|
if !isViewing && shouldMarkUnread {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
||||||
from: senderName,
|
|
||||||
message: pm.content,
|
|
||||||
peerID: convKey
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
@@ -425,15 +458,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
guard !context.sentGeoDeliveryAcks.contains(messageId) else { return }
|
guard context.markGeoDeliveryAckSent(messageId) else { return }
|
||||||
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
context.sentGeoDeliveryAcks.insert(messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
guard !context.sentReadReceipts.contains(messageId) else { return }
|
guard context.markReadReceiptSent(messageId) else { return }
|
||||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
context.sentReadReceipts.insert(messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePrivateMessage(
|
func handlePrivateMessage(
|
||||||
@@ -579,14 +610,10 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
readerNickname: context.nickname
|
readerNickname: context.nickname
|
||||||
)
|
)
|
||||||
context.sendMeshReadReceipt(receipt, to: peerID)
|
context.sendMeshReadReceipt(receipt, to: peerID)
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
} else {
|
} else {
|
||||||
context.unreadPrivateMessages.insert(peerID)
|
context.unreadPrivateMessages.insert(peerID)
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
|
||||||
from: message.sender,
|
|
||||||
message: message.content,
|
|
||||||
peerID: peerID
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.notifyUIChanged()
|
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)
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
} else if let identity = context.currentNostrIdentity() {
|
} else if let identity = context.currentNostrIdentity() {
|
||||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||||
context.sentReadReceipts.insert(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||||
category: .session
|
category: .session
|
||||||
@@ -682,11 +709,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
context.unreadPrivateMessages.insert(ephemeralPeerID)
|
context.unreadPrivateMessages.insert(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
if isRecentMessage {
|
if isRecentMessage {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
||||||
from: senderNickname,
|
|
||||||
message: messageContent,
|
|
||||||
peerID: targetPeerID
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,12 +729,12 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
|
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
|
||||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
context.updatePeerFavoritedUs(
|
||||||
peerNoisePublicKey: finalNoiseKey,
|
noiseKey: finalNoiseKey,
|
||||||
favorited: isFavorite,
|
favorited: isFavorite,
|
||||||
peerNickname: senderNickname,
|
nickname: senderNickname,
|
||||||
peerNostrPublicKey: nostrPubkey
|
nostrPublicKey: nostrPubkey
|
||||||
)
|
)
|
||||||
|
|
||||||
if isFavorite && nostrPubkey != nil {
|
if isFavorite && nostrPubkey != nil {
|
||||||
@@ -802,17 +825,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !oldPeerIDsToRemove.isEmpty {
|
if !oldPeerIDsToRemove.isEmpty {
|
||||||
let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
|
|
||||||
|
|
||||||
for oldID in oldPeerIDsToRemove {
|
for oldID in oldPeerIDsToRemove {
|
||||||
context.privateChats.removeValue(forKey: oldID)
|
context.privateChats.removeValue(forKey: oldID)
|
||||||
context.unreadPrivateMessages.remove(oldID)
|
context.unreadPrivateMessages.remove(oldID)
|
||||||
context.clearStoredFingerprint(for: oldID)
|
context.clearStoredFingerprint(for: oldID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsSelectedUpdate {
|
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
|
||||||
context.selectedPrivateChatPeer = peerID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !migratedMessages.isEmpty {
|
if !migratedMessages.isEmpty {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
var currentGeohash: String? { get }
|
var currentGeohash: String? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var myPeerID: PeerID { get }
|
var myPeerID: PeerID { get }
|
||||||
var isBatchingPublic: Bool { get set }
|
/// Publishes the public-timeline batching state (UI animation suppression).
|
||||||
|
/// (Single mutation path for the owner's `isBatchingPublic`; this
|
||||||
|
/// coordinator never reads it.)
|
||||||
|
func setPublicBatching(_ isBatching: Bool)
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
func trimMessagesIfNeeded()
|
func trimMessagesIfNeeded()
|
||||||
@@ -56,7 +59,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
// MARK: Geohash participants & presence
|
// MARK: Geohash participants & presence
|
||||||
var geoNicknames: [String: String] { get }
|
var geoNicknames: [String: String] { get }
|
||||||
var isTeleported: Bool { get }
|
var isTeleported: Bool { get }
|
||||||
var nostrKeyMapping: [PeerID: String] { get set }
|
var nostrKeyMapping: [PeerID: String] { get }
|
||||||
|
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
|
||||||
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String)
|
||||||
func visibleGeoPeople() -> [GeoPerson]
|
func visibleGeoPeople() -> [GeoPerson]
|
||||||
func geoParticipantCount(for geohash: String) -> Int
|
func geoParticipantCount(for geohash: String) -> Int
|
||||||
func removeGeoParticipant(pubkeyHex: String)
|
func removeGeoParticipant(pubkeyHex: String)
|
||||||
@@ -83,12 +88,16 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
func recordContentKey(_ key: String, timestamp: Date)
|
func recordContentKey(_ key: String, timestamp: Date)
|
||||||
/// Pre-renders the message so the formatting cache is warm before display.
|
/// Pre-renders the message so the formatting cache is warm before display.
|
||||||
func prewarmMessageFormatting(_ message: BitchatMessage)
|
func prewarmMessageFormatting(_ message: BitchatMessage)
|
||||||
|
|
||||||
|
// MARK: Notifications
|
||||||
|
/// Posts the you-were-mentioned local notification.
|
||||||
|
func notifyMention(from sender: String, message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatPublicConversationContext {
|
extension ChatViewModel: ChatPublicConversationContext {
|
||||||
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
||||||
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
||||||
// `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`,
|
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
|
||||||
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
||||||
// `deriveNostrIdentity(forGeohash:)`, and
|
// `deriveNostrIdentity(forGeohash:)`, and
|
||||||
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
|
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
|
||||||
@@ -179,6 +188,10 @@ extension ChatViewModel: ChatPublicConversationContext {
|
|||||||
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
||||||
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
|
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notifyMention(from sender: String, message: String) {
|
||||||
|
NotificationService.shared.sendMentionNotification(from: sender, message: message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -247,9 +260,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
context.unreadPrivateMessages = unread
|
context.unreadPrivateMessages = unread
|
||||||
}
|
}
|
||||||
|
|
||||||
for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
|
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
||||||
context.nostrKeyMapping.removeValue(forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
addSystemMessage(
|
addSystemMessage(
|
||||||
String(
|
String(
|
||||||
@@ -545,7 +556,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
|
|
||||||
if isMentioned && message.sender != context.nickname {
|
if isMentioned && message.sender != context.nickname {
|
||||||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
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) {
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
||||||
context.isBatchingPublic = isBatching
|
context.setPublicBatching(isBatching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,139 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ChatTransportEventCoordinator {
|
/// The narrow surface `ChatTransportEventCoordinator` needs from its owner.
|
||||||
private unowned let viewModel: ChatViewModel
|
///
|
||||||
|
/// 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) {
|
// MARK: Inbound message handling
|
||||||
self.viewModel = viewModel
|
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) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
runOnMain { viewModel in
|
runOnMain { context in
|
||||||
guard !viewModel.isMessageBlocked(message) else { return }
|
guard !context.isMessageBlocked(message) else { return }
|
||||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||||
|
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
viewModel.handlePrivateMessage(message)
|
context.handlePrivateMessage(message)
|
||||||
} else {
|
} else {
|
||||||
viewModel.handlePublicMessage(message)
|
context.handlePublicMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
viewModel.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +145,9 @@ final class ChatTransportEventCoordinator {
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
messageID: String?
|
messageID: String?
|
||||||
) {
|
) {
|
||||||
runOnMain { viewModel in
|
runOnMain { context in
|
||||||
let normalized = content.trimmed
|
let normalized = content.trimmed
|
||||||
let mentions = viewModel.parseMentions(from: normalized)
|
let mentions = context.parseMentions(from: normalized)
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
@@ -48,9 +161,9 @@ final class ChatTransportEventCoordinator {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.handlePublicMessage(message)
|
context.handlePublicMessage(message)
|
||||||
viewModel.checkForMentions(message)
|
context.checkForMentions(message)
|
||||||
viewModel.sendHapticFeedback(for: message)
|
context.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +173,13 @@ final class ChatTransportEventCoordinator {
|
|||||||
payload: Data,
|
payload: Data,
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
) {
|
) {
|
||||||
runOnMain { [self] viewModel in
|
runOnMain { [self] context in
|
||||||
handleNoisePayload(
|
handleNoisePayload(
|
||||||
from: peerID,
|
from: peerID,
|
||||||
type: type,
|
type: type,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
in: viewModel
|
in: context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,60 +187,61 @@ final class ChatTransportEventCoordinator {
|
|||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||||
|
|
||||||
runOnMain { viewModel in
|
runOnMain { context in
|
||||||
viewModel.isConnected = true
|
context.isConnected = true
|
||||||
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
|
|
||||||
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
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) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||||
|
|
||||||
runOnMain { viewModel in
|
runOnMain { context in
|
||||||
viewModel.identityManager.removeEphemeralSession(peerID: peerID)
|
context.removeEphemeralSession(peerID: peerID)
|
||||||
|
|
||||||
var stablePeerID = viewModel.cachedStablePeerID(for: peerID)
|
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||||
if stablePeerID == nil,
|
if stablePeerID == nil,
|
||||||
let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let derivedPeerID = PeerID(hexData: key)
|
let derivedPeerID = PeerID(hexData: key)
|
||||||
viewModel.cacheStablePeerID(derivedPeerID, for: peerID)
|
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||||
stablePeerID = derivedPeerID
|
stablePeerID = derivedPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
if let currentPeerID = viewModel.selectedPrivateChatPeer,
|
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||||
currentPeerID == peerID,
|
currentPeerID == peerID,
|
||||||
let stablePeerID {
|
let stablePeerID {
|
||||||
self.migrateSelectedConversationIfNeeded(
|
self.migrateSelectedConversationIfNeeded(
|
||||||
from: peerID,
|
from: peerID,
|
||||||
to: stablePeerID,
|
to: stablePeerID,
|
||||||
in: viewModel
|
in: context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let messages = viewModel.privateChats[peerID] {
|
if let messages = context.privateChats[peerID] {
|
||||||
for message in messages where message.senderPeerID == peerID {
|
let receiptIDs = messages
|
||||||
viewModel.sentReadReceipts.remove(message.id)
|
.filter { $0.senderPeerID == peerID }
|
||||||
}
|
.map(\.id)
|
||||||
|
context.unmarkReadReceiptsSent(receiptIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatTransportEventCoordinator {
|
private extension ChatTransportEventCoordinator {
|
||||||
func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) {
|
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||||
Task { @MainActor [weak viewModel = self.viewModel] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let viewModel else { return }
|
guard let context else { return }
|
||||||
action(viewModel)
|
action(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +249,14 @@ private extension ChatTransportEventCoordinator {
|
|||||||
func migrateSelectedConversationIfNeeded(
|
func migrateSelectedConversationIfNeeded(
|
||||||
from shortPeerID: PeerID,
|
from shortPeerID: PeerID,
|
||||||
to stablePeerID: PeerID,
|
to stablePeerID: PeerID,
|
||||||
in viewModel: ChatViewModel
|
in context: any ChatTransportEventContext
|
||||||
) {
|
) {
|
||||||
if let messages = viewModel.privateChats[shortPeerID] {
|
if let messages = context.privateChats[shortPeerID] {
|
||||||
if viewModel.privateChats[stablePeerID] == nil {
|
if context.privateChats[stablePeerID] == nil {
|
||||||
viewModel.privateChats[stablePeerID] = []
|
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) {
|
for message in messages where !existingIDs.contains(message.id) {
|
||||||
let migrated = BitchatMessage(
|
let migrated = BitchatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -153,25 +267,25 @@ private extension ChatTransportEventCoordinator {
|
|||||||
originalSender: message.originalSender,
|
originalSender: message.originalSender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID
|
senderPeerID: message.senderPeerID == context.myPeerID
|
||||||
? viewModel.meshService.myPeerID
|
? context.myPeerID
|
||||||
: stablePeerID,
|
: stablePeerID,
|
||||||
mentions: message.mentions,
|
mentions: message.mentions,
|
||||||
deliveryStatus: message.deliveryStatus
|
deliveryStatus: message.deliveryStatus
|
||||||
)
|
)
|
||||||
viewModel.privateChats[stablePeerID]?.append(migrated)
|
context.privateChats[stablePeerID]?.append(migrated)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
|
context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
|
||||||
viewModel.privateChats.removeValue(forKey: shortPeerID)
|
context.privateChats.removeValue(forKey: shortPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.unreadPrivateMessages.contains(shortPeerID) {
|
if context.unreadPrivateMessages.contains(shortPeerID) {
|
||||||
viewModel.unreadPrivateMessages.remove(shortPeerID)
|
context.unreadPrivateMessages.remove(shortPeerID)
|
||||||
viewModel.unreadPrivateMessages.insert(stablePeerID)
|
context.unreadPrivateMessages.insert(stablePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.selectedPrivateChatPeer = stablePeerID
|
context.selectedPrivateChatPeer = stablePeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -180,19 +294,19 @@ private extension ChatTransportEventCoordinator {
|
|||||||
type: NoisePayloadType,
|
type: NoisePayloadType,
|
||||||
payload: Data,
|
payload: Data,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
in viewModel: ChatViewModel
|
in context: any ChatTransportEventContext
|
||||||
) {
|
) {
|
||||||
switch type {
|
switch type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
guard let packet = PrivateMessagePacket.decode(from: payload) else { return }
|
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)
|
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
|
let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown"
|
||||||
let mentions = viewModel.parseMentions(from: packet.content)
|
let mentions = context.parseMentions(from: packet.content)
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: packet.messageID,
|
id: packet.messageID,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
@@ -201,24 +315,24 @@ private extension ChatTransportEventCoordinator {
|
|||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: viewModel.nickname,
|
recipientNickname: context.nickname,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
viewModel.handlePrivateMessage(message)
|
context.handlePrivateMessage(message)
|
||||||
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
|
context.sendMeshDeliveryAck(for: packet.messageID, to: peerID)
|
||||||
|
|
||||||
case .delivered:
|
case .delivered:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: viewModel)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
let didUpdate = context.applyMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .delivered(to: name, at: Date())
|
status: .delivered(to: name, at: Date())
|
||||||
)
|
)
|
||||||
|
|
||||||
if !didUpdate {
|
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)
|
SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
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:
|
case .readReceipt:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
|
|
||||||
let name = deliveryStatusName(for: peerID, in: viewModel)
|
let name = deliveryStatusName(for: peerID, in: context)
|
||||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
let didUpdate = context.applyMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .read(by: name, at: Date())
|
status: .read(by: name, at: Date())
|
||||||
)
|
)
|
||||||
@@ -239,15 +353,15 @@ private extension ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
|
context.handleVerifyChallengePayload(from: peerID, payload: payload)
|
||||||
|
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
|
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String {
|
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
|
||||||
viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID)
|
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,123 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
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
|
@MainActor
|
||||||
final class ChatVerificationCoordinator {
|
final class ChatVerificationCoordinator {
|
||||||
struct PendingVerification {
|
struct PendingVerification {
|
||||||
@@ -13,44 +130,44 @@ final class ChatVerificationCoordinator {
|
|||||||
var sent: Bool
|
var sent: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let context: any ChatVerificationContext
|
||||||
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
|
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
|
||||||
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
|
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
|
||||||
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
|
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
|
||||||
private var lastMutualToastAt: [String: Date] = [:]
|
private var lastMutualToastAt: [String: Date] = [:]
|
||||||
|
|
||||||
init(viewModel: ChatViewModel) {
|
init(context: any ChatVerificationContext) {
|
||||||
self.viewModel = viewModel
|
self.context = context
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyFingerprint(for peerID: PeerID) {
|
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)
|
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
|
||||||
viewModel.saveIdentityState()
|
context.saveIdentityState()
|
||||||
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
|
context.setStoredVerified(fingerprint, verified: true)
|
||||||
viewModel.updateEncryptionStatus(for: peerID)
|
context.updateEncryptionStatus(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unverifyFingerprint(for peerID: PeerID) {
|
func unverifyFingerprint(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: false)
|
context.setIdentityVerified(fingerprint: fingerprint, verified: false)
|
||||||
viewModel.saveIdentityState()
|
context.saveIdentityState()
|
||||||
viewModel.peerIdentityStore.setVerified(fingerprint, verified: false)
|
context.setStoredVerified(fingerprint, verified: false)
|
||||||
viewModel.updateEncryptionStatus(for: peerID)
|
context.updateEncryptionStatus(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadVerifiedFingerprints() {
|
func loadVerifiedFingerprints() {
|
||||||
viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints())
|
context.verifiedFingerprints = context.persistedVerifiedFingerprints()
|
||||||
let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
|
let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
|
||||||
.map { $0.prefix(8) }
|
.map { $0.prefix(8) }
|
||||||
.joined(separator: ", ")
|
.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 {
|
for favorite in offlineFavorites {
|
||||||
let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID)
|
let fingerprint = context.unifiedFingerprint(for: favorite.peerID)
|
||||||
let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false
|
let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false
|
||||||
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
|
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
|
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
|
||||||
@@ -58,62 +175,61 @@ final class ChatVerificationCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.invalidateEncryptionCache()
|
context.invalidateEncryptionCache(for: nil)
|
||||||
viewModel.objectWillChange.send()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupNoiseCallbacks() {
|
func setupNoiseCallbacks() {
|
||||||
let noiseService = viewModel.meshService.getNoiseService()
|
context.installNoiseSessionCallbacks(
|
||||||
|
onPeerAuthenticated: { [weak self] peerID, fingerprint in
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
if self.context.isVerifiedFingerprint(fingerprint) {
|
||||||
|
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
|
||||||
|
} else {
|
||||||
|
self.context.setEncryptionStatus(.noiseSecured, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
if self.viewModel.peerIdentityStore.isVerified(fingerprint) {
|
self.context.invalidateEncryptionCache(for: peerID)
|
||||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID)
|
|
||||||
} else {
|
if self.context.cachedStablePeerID(for: peerID) == nil,
|
||||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID)
|
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
|
let stablePeerID = PeerID(hexData: keyData)
|
||||||
|
self.context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
||||||
|
self.context.sendVerifyChallenge(
|
||||||
|
to: peerID,
|
||||||
|
noiseKeyHex: pending.noiseKeyHex,
|
||||||
|
nonceA: pending.nonceA
|
||||||
|
)
|
||||||
|
pending.sent = true
|
||||||
|
self.pendingQRVerifications[peerID] = pending
|
||||||
|
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
self.viewModel.invalidateEncryptionCache(for: peerID)
|
onHandshakeRequired: { [weak self] peerID in
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
if self.viewModel.cachedStablePeerID(for: peerID) == nil,
|
guard let self else { return }
|
||||||
let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
self.context.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
||||||
let stablePeerID = PeerID(hexData: keyData)
|
self.context.invalidateEncryptionCache(for: peerID)
|
||||||
self.viewModel.cacheStablePeerID(stablePeerID, for: peerID)
|
|
||||||
SecureLogger.debug(
|
|
||||||
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
|
|
||||||
self.viewModel.meshService.sendVerifyChallenge(
|
|
||||||
to: peerID,
|
|
||||||
noiseKeyHex: pending.noiseKeyHex,
|
|
||||||
nonceA: pending.nonceA
|
|
||||||
)
|
|
||||||
pending.sent = true
|
|
||||||
self.pendingQRVerifications[peerID] = pending
|
|
||||||
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
noiseService.onHandshakeRequired = { [weak self] peerID in
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
|
||||||
self.viewModel.invalidateEncryptionCache(for: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
let targetNoise = qr.noiseKeyHex.lowercased()
|
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
|
$0.noisePublicKey.hexEncodedString().lowercased() == targetNoise
|
||||||
}) else {
|
}) else {
|
||||||
return false
|
return false
|
||||||
@@ -135,13 +251,12 @@ final class ChatVerificationCoordinator {
|
|||||||
)
|
)
|
||||||
pendingQRVerifications[peerID] = pending
|
pendingQRVerifications[peerID] = pending
|
||||||
|
|
||||||
let noise = viewModel.meshService.getNoiseService()
|
if context.hasEstablishedNoiseSession(with: peerID) {
|
||||||
if noise.hasEstablishedSession(with: peerID) {
|
context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
||||||
viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
|
|
||||||
pending.sent = true
|
pending.sent = true
|
||||||
pendingQRVerifications[peerID] = pending
|
pendingQRVerifications[peerID] = pending
|
||||||
} else {
|
} else {
|
||||||
viewModel.meshService.triggerHandshake(with: peerID)
|
context.triggerHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -150,9 +265,7 @@ final class ChatVerificationCoordinator {
|
|||||||
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
|
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
|
||||||
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
|
||||||
|
|
||||||
let myNoiseHex = viewModel.meshService
|
let myNoiseHex = context.noiseStaticPublicKeyData()
|
||||||
.getNoiseService()
|
|
||||||
.getStaticPublicKeyData()
|
|
||||||
.hexEncodedString()
|
.hexEncodedString()
|
||||||
.lowercased()
|
.lowercased()
|
||||||
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
|
||||||
@@ -160,22 +273,22 @@ final class ChatVerificationCoordinator {
|
|||||||
|
|
||||||
lastVerifyNonceByPeer[peerID] = challenge.nonceA
|
lastVerifyNonceByPeer[peerID] = challenge.nonceA
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
if let fingerprint = context.getFingerprint(for: peerID) {
|
||||||
lastInboundVerifyChallengeAt[fingerprint] = Date()
|
lastInboundVerifyChallengeAt[fingerprint] = Date()
|
||||||
|
|
||||||
if viewModel.peerIdentityStore.isVerified(fingerprint) {
|
if context.isVerifiedFingerprint(fingerprint) {
|
||||||
maybeSendMutualVerificationNotification(
|
maybeSendMutualVerificationNotification(
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
title: "Mutual verification",
|
title: "Mutual verification",
|
||||||
bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
|
bodyName: context.unifiedPeer(for: peerID)?.nickname
|
||||||
?? viewModel.resolveNickname(for: peerID),
|
?? context.resolveNickname(for: peerID),
|
||||||
notificationPrefix: "verify-mutual"
|
notificationPrefix: "verify-mutual"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.meshService.sendVerifyResponse(
|
context.sendVerifyResponse(
|
||||||
to: peerID,
|
to: peerID,
|
||||||
noiseKeyHex: challenge.noiseKeyHex,
|
noiseKeyHex: challenge.noiseKeyHex,
|
||||||
nonceA: challenge.nonceA
|
nonceA: challenge.nonceA
|
||||||
@@ -198,17 +311,17 @@ final class ChatVerificationCoordinator {
|
|||||||
|
|
||||||
pendingQRVerifications.removeValue(forKey: peerID)
|
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)
|
let shortFingerprint = fingerprint.prefix(8)
|
||||||
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
|
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
|
||||||
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
|
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
|
||||||
viewModel.saveIdentityState()
|
context.saveIdentityState()
|
||||||
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
|
context.setStoredVerified(fingerprint, verified: true)
|
||||||
|
|
||||||
let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
|
let peerName = context.unifiedPeer(for: peerID)?.nickname
|
||||||
?? viewModel.resolveNickname(for: peerID)
|
?? context.resolveNickname(for: peerID)
|
||||||
NotificationService.shared.sendLocalNotification(
|
context.postLocalNotification(
|
||||||
title: "Verified",
|
title: "Verified",
|
||||||
body: "You verified \(peerName)",
|
body: "You verified \(peerName)",
|
||||||
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
|
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 }
|
guard now.timeIntervalSince(lastToast) > 60 else { return }
|
||||||
|
|
||||||
lastMutualToastAt[fingerprint] = now
|
lastMutualToastAt[fingerprint] = now
|
||||||
NotificationService.shared.sendLocalNotification(
|
context.postLocalNotification(
|
||||||
title: title,
|
title: title,
|
||||||
body: "You and \(bodyName) verified each other",
|
body: "You and \(bodyName) verified each other",
|
||||||
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
|
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
|
||||||
|
|||||||
@@ -146,19 +146,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
let unifiedPeerService: UnifiedPeerService
|
let unifiedPeerService: UnifiedPeerService
|
||||||
let autocompleteService: AutocompleteService
|
let autocompleteService: AutocompleteService
|
||||||
let deduplicationService: MessageDeduplicationService // internal for test access
|
let deduplicationService: MessageDeduplicationService // internal for test access
|
||||||
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self)
|
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self)
|
||||||
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self)
|
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self)
|
||||||
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self)
|
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self)
|
||||||
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
|
private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self)
|
||||||
private lazy var messageFormatter = ChatMessageFormatter(viewModel: 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 deliveryCoordinator = ChatDeliveryCoordinator(context: self)
|
||||||
lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self)
|
lazy var composerCoordinator = ChatComposerCoordinator(context: self)
|
||||||
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
|
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
|
||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
|
|
||||||
// Computed properties for compatibility
|
// Computed properties for compatibility
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -292,8 +292,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var geoSubscriptionID: String? = nil
|
// Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below.
|
||||||
var geoDmSubscriptionID: String? = nil
|
private(set) var geoSubscriptionID: String? = nil
|
||||||
|
private(set) var geoDmSubscriptionID: String? = nil
|
||||||
var currentGeohash: String? {
|
var currentGeohash: String? {
|
||||||
get { locationPresenceStore.currentGeohash }
|
get { locationPresenceStore.currentGeohash }
|
||||||
set { locationPresenceStore.setCurrentGeohash(newValue) }
|
set { locationPresenceStore.setCurrentGeohash(newValue) }
|
||||||
@@ -366,7 +367,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
|
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
|
||||||
} // lowercased pubkey hex
|
} // lowercased pubkey hex
|
||||||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
||||||
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
// Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below.
|
||||||
|
private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||||||
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
||||||
|
|
||||||
// MARK: - Message Delivery Tracking
|
// MARK: - Message Delivery Tracking
|
||||||
@@ -384,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// MARK: - Public message batching (UI perf)
|
// MARK: - Public message batching (UI perf)
|
||||||
let publicMessagePipeline: PublicMessagePipeline
|
let publicMessagePipeline: PublicMessagePipeline
|
||||||
@Published var isBatchingPublic: Bool = false
|
// Single-writer: mutate only via `setPublicBatching(_:)` below.
|
||||||
|
@Published private(set) var isBatchingPublic: Bool = false
|
||||||
|
|
||||||
// Track sent read receipts to avoid duplicates (persisted across launches)
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
||||||
@@ -403,7 +406,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
|
||||||
var sentGeoDeliveryAcks: Set<String> = []
|
// Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below.
|
||||||
|
private(set) var sentGeoDeliveryAcks: Set<String> = []
|
||||||
|
|
||||||
// Track app startup phase to prevent marking old messages as unread
|
// Track app startup phase to prevent marking old messages as unread
|
||||||
var isStartupPhase = true
|
var isStartupPhase = true
|
||||||
@@ -411,7 +415,115 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var torInitialReadyAnnounced: Bool = false
|
var torInitialReadyAnnounced: Bool = false
|
||||||
|
|
||||||
// Track Nostr pubkey mappings for unknown senders
|
// Track Nostr pubkey mappings for unknown senders
|
||||||
var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
|
// Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below.
|
||||||
|
private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
|
||||||
|
|
||||||
|
// MARK: - Single-Writer Intent Operations
|
||||||
|
// Owner-side mutation paths for state the coordinator contexts may read
|
||||||
|
// but not write directly. Each op is the sole way to mutate its backing
|
||||||
|
// state, so check-then-mutate races between coordinators cannot occur.
|
||||||
|
|
||||||
|
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
||||||
|
@MainActor
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
|
||||||
|
nostrKeyMapping[peerID] = pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
|
||||||
|
@MainActor
|
||||||
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
|
||||||
|
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
|
||||||
|
nostrKeyMapping.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
|
||||||
|
sentGeoDeliveryAcks.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
|||||||
@@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
func configureGeoChannels() {
|
func configureGeoChannels() {
|
||||||
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
|
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
|
||||||
locationManager: viewModel.locationManager,
|
locationManager: viewModel.locationManager,
|
||||||
onChannelSwitch: { [weak viewModel] channel in
|
context: viewModel
|
||||||
viewModel?.switchLocationChannel(to: channel)
|
|
||||||
},
|
|
||||||
beginSampling: { [weak viewModel] geohashes in
|
|
||||||
viewModel?.beginGeohashSampling(for: geohashes)
|
|
||||||
},
|
|
||||||
endSampling: { [weak viewModel] in
|
|
||||||
viewModel?.endGeohashSampling()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,86 +12,86 @@ extension ChatViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func resubscribeCurrentGeohash() {
|
func resubscribeCurrentGeohash() {
|
||||||
nostrCoordinator.resubscribeCurrentGeohash()
|
nostrCoordinator.subscriptions.resubscribeCurrentGeohash()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
nostrCoordinator.subscribeNostrEvent(event)
|
nostrCoordinator.inbound.subscribeNostrEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
func switchLocationChannel(to channel: ChannelID) {
|
||||||
nostrCoordinator.switchLocationChannel(to: channel)
|
nostrCoordinator.subscriptions.switchLocationChannel(to: channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
nostrCoordinator.handleNostrEvent(event)
|
nostrCoordinator.inbound.handleNostrEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||||
nostrCoordinator.subscribeToGeoChat(ch)
|
nostrCoordinator.subscriptions.subscribeToGeoChat(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendGeohash(context: GeoOutgoingContext) {
|
func sendGeohash(context: GeoOutgoingContext) {
|
||||||
nostrCoordinator.sendGeohash(context: context)
|
nostrCoordinator.subscriptions.sendGeohash(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
func beginGeohashSampling(for geohashes: [String]) {
|
||||||
nostrCoordinator.beginGeohashSampling(for: geohashes)
|
nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribe(_ gh: String) {
|
func subscribe(_ gh: String) {
|
||||||
nostrCoordinator.subscribe(gh)
|
nostrCoordinator.subscriptions.subscribe(gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||||
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
|
nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||||
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
|
nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func endGeohashSampling() {
|
func endGeohashSampling() {
|
||||||
nostrCoordinator.endGeohashSampling()
|
nostrCoordinator.subscriptions.endGeohashSampling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func setupNostrMessageHandling() {
|
func setupNostrMessageHandling() {
|
||||||
nostrCoordinator.setupNostrMessageHandling()
|
nostrCoordinator.subscriptions.setupNostrMessageHandling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
nostrCoordinator.handleNostrMessage(giftWrap)
|
nostrCoordinator.inbound.handleNostrMessage(giftWrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
await nostrCoordinator.processNostrMessage(giftWrap)
|
await nostrCoordinator.inbound.processNostrMessage(giftWrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -9,15 +9,32 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
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
|
@MainActor
|
||||||
final class GeoChannelCoordinator {
|
final class GeoChannelCoordinator {
|
||||||
private let locationManager: LocationChannelManager
|
private let locationManager: LocationChannelManager
|
||||||
private let bookmarksStore: GeohashBookmarksStore
|
private let bookmarksStore: GeohashBookmarksStore
|
||||||
private let torManager: TorManager
|
private let torManager: TorManager
|
||||||
|
|
||||||
private let onChannelSwitch: (ChannelID) -> Void
|
private weak var context: (any GeoChannelContext)?
|
||||||
private let beginSampling: ([String]) -> Void
|
|
||||||
private let endSampling: () -> Void
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var regionalGeohashes: [String] = []
|
private var regionalGeohashes: [String] = []
|
||||||
@@ -27,16 +44,12 @@ final class GeoChannelCoordinator {
|
|||||||
locationManager: LocationChannelManager? = nil,
|
locationManager: LocationChannelManager? = nil,
|
||||||
bookmarksStore: GeohashBookmarksStore? = nil,
|
bookmarksStore: GeohashBookmarksStore? = nil,
|
||||||
torManager: TorManager? = nil,
|
torManager: TorManager? = nil,
|
||||||
onChannelSwitch: @escaping (ChannelID) -> Void,
|
context: any GeoChannelContext
|
||||||
beginSampling: @escaping ([String]) -> Void,
|
|
||||||
endSampling: @escaping () -> Void
|
|
||||||
) {
|
) {
|
||||||
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
self.locationManager = locationManager ?? Self.defaultLocationManager()
|
||||||
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
|
||||||
self.torManager = torManager ?? Self.defaultTorManager()
|
self.torManager = torManager ?? Self.defaultTorManager()
|
||||||
self.onChannelSwitch = onChannelSwitch
|
self.context = context
|
||||||
self.beginSampling = beginSampling
|
|
||||||
self.endSampling = endSampling
|
|
||||||
|
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
@@ -50,7 +63,7 @@ final class GeoChannelCoordinator {
|
|||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.onChannelSwitch(channel)
|
self.context?.switchLocationChannel(to: channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -84,7 +97,7 @@ final class GeoChannelCoordinator {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.onChannelSwitch(self.locationManager.selectedChannel)
|
self.context?.switchLocationChannel(to: self.locationManager.selectedChannel)
|
||||||
}
|
}
|
||||||
updateSampling()
|
updateSampling()
|
||||||
}
|
}
|
||||||
@@ -93,13 +106,13 @@ final class GeoChannelCoordinator {
|
|||||||
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard !union.isEmpty else {
|
guard !union.isEmpty else {
|
||||||
endSampling()
|
context?.endGeohashSampling()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if torManager.isForeground() {
|
if torManager.isForeground() {
|
||||||
beginSampling(union)
|
context?.beginGeohashSampling(for: union)
|
||||||
} else {
|
} 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
|
// ChatNostrCoordinatorContextTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` —
|
// Exercises the `ChatNostrCoordinator` facade and its components
|
||||||
// proving the coordinator works without a `ChatViewModel`, following the
|
// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`)
|
||||||
// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests`
|
// against a mock `ChatNostrContext` — proving the stack works without a
|
||||||
// exemplars.
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||||
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@@ -15,8 +16,9 @@ import BitFoundation
|
|||||||
|
|
||||||
// MARK: - Mock Context
|
// MARK: - Mock Context
|
||||||
|
|
||||||
/// Lightweight stand-in for `ChatNostrContext` proving that
|
/// Lightweight stand-in for `ChatNostrContext` (and, via protocol
|
||||||
/// `ChatNostrCoordinator` is testable without a `ChatViewModel`.
|
/// inheritance, the component contexts) proving that the Nostr stack is
|
||||||
|
/// testable without a `ChatViewModel`.
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatNostrContext: ChatNostrContext {
|
private final class MockChatNostrContext: ChatNostrContext {
|
||||||
// Channel & subscription state
|
// Channel & subscription state
|
||||||
@@ -28,6 +30,16 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
var lastGeoNotificationAt: [String: Date] = [:]
|
var lastGeoNotificationAt: [String: Date] = [:]
|
||||||
var nostrRelayManager: NostrRelayManager? { nil }
|
var nostrRelayManager: NostrRelayManager? { nil }
|
||||||
|
|
||||||
|
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
|
||||||
|
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
|
||||||
|
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
|
||||||
|
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
|
||||||
|
|
||||||
|
func clearGeoSamplingSubs() -> [String] {
|
||||||
|
defer { geoSamplingSubs.removeAll() }
|
||||||
|
return Array(geoSamplingSubs.keys)
|
||||||
|
}
|
||||||
|
|
||||||
// Public timeline & pipeline
|
// Public timeline & pipeline
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
private(set) var pipelineResetCount = 0
|
private(set) var pipelineResetCount = 0
|
||||||
@@ -71,6 +83,7 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
// Inbound private (geohash DM) payloads
|
// Inbound private (geohash DM) payloads
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var nostrKeyMapping: [PeerID: String] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
||||||
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
|
||||||
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
|
||||||
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
|
||||||
@@ -202,6 +215,32 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
geoReadReceipts.append((messageID, recipientHex))
|
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
|
// 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
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||||
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
|
||||||
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
|
||||||
/// teardown, and embedded ack flows. Flows that hit live singletons
|
/// teardown, embedded ack flows, and — now that favorites and notifications
|
||||||
/// (`NostrRelayManager.shared` subscriptions, `TorManager`,
|
/// are injected through the context — the favorite-notification ingest and
|
||||||
/// `FavoritesPersistenceService`) remain covered by the full view-model tests.
|
/// the sampled-geohash notification cooldown. Flows that hit live singletons
|
||||||
|
/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by
|
||||||
|
/// the full view-model tests.
|
||||||
struct ChatNostrCoordinatorContextTests {
|
struct ChatNostrCoordinatorContextTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -239,7 +298,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// Dedup recorded exactly once, presence and key mapping updated.
|
// Dedup recorded exactly once, presence and key mapping updated.
|
||||||
@@ -257,7 +316,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(context.hapticMessageIDs == [event.id])
|
#expect(context.hapticMessageIDs == [event.id])
|
||||||
|
|
||||||
// A replay of the same event is dropped before any processing.
|
// A replay of the same event is dropped before any processing.
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
#expect(context.recordedNostrEventIDs == [event.id])
|
#expect(context.recordedNostrEventIDs == [event.id])
|
||||||
#expect(context.handledPublicMessages.count == 1)
|
#expect(context.handledPublicMessages.count == 1)
|
||||||
@@ -277,7 +336,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
teleported: true
|
teleported: true
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// Teleport detection fires even though the empty message is dropped.
|
// Teleport detection fires even though the empty message is dropped.
|
||||||
@@ -299,7 +358,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// The event is still recorded for dedup but nothing else happens.
|
// The event is still recorded for dedup but nothing else happens.
|
||||||
@@ -326,7 +385,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
@@ -343,11 +402,35 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(pm.content == "psst")
|
#expect(pm.content == "psst")
|
||||||
|
|
||||||
// The same gift wrap is dropped on replay.
|
// 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.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#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
|
@Test @MainActor
|
||||||
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
|
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
|
||||||
let context = MockChatNostrContext()
|
let context = MockChatNostrContext()
|
||||||
@@ -356,7 +439,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
context.currentGeohash = "u4pruyd"
|
context.currentGeohash = "u4pruyd"
|
||||||
context.geoNicknames = ["abcd": "alice"]
|
context.geoNicknames = ["abcd": "alice"]
|
||||||
|
|
||||||
coordinator.switchLocationChannel(to: .mesh)
|
coordinator.subscriptions.switchLocationChannel(to: .mesh)
|
||||||
|
|
||||||
#expect(context.pipelineResetCount == 1)
|
#expect(context.pipelineResetCount == 1)
|
||||||
#expect(context.activeChannel == .mesh)
|
#expect(context.activeChannel == .mesh)
|
||||||
@@ -440,3 +523,179 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
|
#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] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
|
sentReadReceipts.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
|
||||||
|
sentGeoDeliveryAcks.insert(messageID).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
|
||||||
|
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
|
||||||
|
selectedPrivateChatPeer = newPeerID
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
@@ -117,6 +132,23 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
embeddedDeliveryAckMessageIDs.append(message.id)
|
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
|
// System messages & chat hygiene
|
||||||
private(set) var systemMessages: [String] = []
|
private(set) var systemMessages: [String] = []
|
||||||
private(set) var meshOnlySystemMessages: [String] = []
|
private(set) var meshOnlySystemMessages: [String] = []
|
||||||
@@ -176,13 +208,33 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
|
|||||||
return false
|
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
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatPrivateConversationCoordinator` against
|
/// Exercises `ChatPrivateConversationCoordinator` against
|
||||||
/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the
|
/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the
|
||||||
/// pure-state and ack flows; flows that hit `NotificationService` /
|
/// pure-state and ack flows plus — now that notifications and favorites are
|
||||||
/// `FavoritesPersistenceService` singletons remain covered by the full
|
/// injected through the context (`notifyPrivateMessage`,
|
||||||
/// view-model tests.
|
/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) — the
|
||||||
|
/// notification and favorite-transition flows that previously required the
|
||||||
|
/// live singletons.
|
||||||
struct ChatPrivateConversationCoordinatorContextTests {
|
struct ChatPrivateConversationCoordinatorContextTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -387,4 +439,99 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.sanitizedPeerIDs == [newPeerID])
|
#expect(context.sanitizedPeerIDs == [newPeerID])
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#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` /
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
// Scope note: flows that hit process-wide singletons are intentionally not
|
// Scope note: haptics (UIApplication) and the geohash branch of
|
||||||
// exercised here — `checkForMentions` / haptics (NotificationService.shared,
|
// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are
|
||||||
// UIApplication) and the geohash branch of `sendPublicRaw`
|
// intentionally not exercised here. `checkForMentions` posts through the
|
||||||
// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of
|
// injected context (`notifyMention(from:message:)`) and is covered, as are
|
||||||
// `sendPublicRaw` and all timeline/store/blocking flows are covered.
|
// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@@ -31,10 +31,14 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
var currentGeohash: String?
|
var currentGeohash: String?
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
var myPeerID = PeerID(str: "0011223344556677")
|
var myPeerID = PeerID(str: "0011223344556677")
|
||||||
var isBatchingPublic = false
|
private(set) var isBatchingPublic = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var trimMessagesCount = 0
|
private(set) var trimMessagesCount = 0
|
||||||
|
|
||||||
|
func setPublicBatching(_ isBatching: Bool) {
|
||||||
|
isBatchingPublic = isBatching
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
@@ -147,6 +151,12 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
var geoParticipantCounts: [String: Int] = [:]
|
var geoParticipantCounts: [String: Int] = [:]
|
||||||
private(set) var removedGeoParticipants: [String] = []
|
private(set) var removedGeoParticipants: [String] = []
|
||||||
|
|
||||||
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
|
||||||
|
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
|
||||||
|
nostrKeyMapping.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func visibleGeoPeople() -> [GeoPerson] {
|
func visibleGeoPeople() -> [GeoPerson] {
|
||||||
geoPeople
|
geoPeople
|
||||||
}
|
}
|
||||||
@@ -239,6 +249,13 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
prewarmedMessageIDs.append(message.id)
|
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(
|
static let dummyIdentity = NostrIdentity(
|
||||||
privateKey: Data(repeating: 0x11, count: 32),
|
privateKey: Data(repeating: 0x11, count: 32),
|
||||||
publicKey: Data(repeating: 0x22, count: 32),
|
publicKey: Data(repeating: 0x22, count: 32),
|
||||||
@@ -492,4 +509,51 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
let unknownHex = String(repeating: "12", count: 32)
|
let unknownHex = String(repeating: "12", count: 32)
|
||||||
#expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4))
|
#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 notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||||
|
|
||||||
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
|
let oldCount = sentReadReceipts.count
|
||||||
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
|
return oldCount - sentReadReceipts.count
|
||||||
|
}
|
||||||
|
|
||||||
func notifyUIChanged() {
|
func notifyUIChanged() {
|
||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
// "Check geohash (Nostr) blocks using mapping to full pubkey"
|
||||||
|
|
||||||
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
|
viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID)
|
||||||
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
|
||||||
|
|
||||||
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
// Force isGeoChat/isGeoDM check to be true by setting prefix?
|
||||||
@@ -235,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
// We need a peerID that looks like geo.
|
// We need a peerID that looks like geo.
|
||||||
|
|
||||||
let geoPeerID = PeerID(nostr_: hexPubkey)
|
let geoPeerID = PeerID(nostr_: hexPubkey)
|
||||||
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
|
viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID)
|
||||||
|
|
||||||
let geoMessage = BitchatMessage(
|
let geoMessage = BitchatMessage(
|
||||||
id: "msg-geo-blocked",
|
id: "msg-geo-blocked",
|
||||||
@@ -398,26 +398,6 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
#expect(viewModel.privateChats.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
@@ -777,7 +757,7 @@ struct ChatViewModelGeoDMTests {
|
|||||||
let convKey = PeerID(nostr_: recipientHex)
|
let convKey = PeerID(nostr_: recipientHex)
|
||||||
|
|
||||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||||
viewModel.nostrKeyMapping[convKey] = recipientHex
|
viewModel.registerNostrKeyMapping(recipientHex, for: convKey)
|
||||||
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
|
||||||
|
|
||||||
viewModel.sendGeohashDM("hello", to: convKey)
|
viewModel.sendGeohashDM("hello", to: convKey)
|
||||||
@@ -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)
|
@Suite(.serialized)
|
||||||
struct ChatViewModelMediaTransferTests {
|
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
|
// MARK: - Global Threshold
|
||||||
|
|
||||||
/// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL.
|
/// 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()
|
let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased()
|
||||||
switch env {
|
switch env {
|
||||||
case "debug": return .debug
|
case "debug": return .debug
|
||||||
@@ -121,23 +122,33 @@ public final class SecureLogger {
|
|||||||
|
|
||||||
public extension 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,
|
static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
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)
|
log(message(), category: category, level: .debug, file: file, line: line, function: function)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
static func info(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
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)
|
log(message(), category: category, level: .info, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
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)
|
log(message(), category: category, level: .warning, file: file, line: line, function: function)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
static func error(_ message: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
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)
|
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