From 7e9d573f6dd86cecba8b7b338e663ae01d915b11 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 25 Aug 2025 21:13:17 +0200 Subject: [PATCH] refactor(config): centralize remaining magic numbers and finalize log hygiene Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe. Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits. --- bitchat/BitchatApp.swift | 4 +- bitchat/Services/BLEService.swift | 62 +++++++------ bitchat/Services/TransportConfig.swift | 76 +++++++++++++++- bitchat/ViewModels/ChatViewModel.swift | 90 ++++++++++--------- bitchat/Views/ContentView.swift | 50 +++++------ .../ShareViewController.swift | 2 +- 6 files changed, 187 insertions(+), 97 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index fcdaa616..dddf4588 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -99,8 +99,8 @@ struct BitchatApp: App { return } - // Only process if shared within last 30 seconds - if Date().timeIntervalSince(sharedDate) < 30 { + // Only process if shared within configured window + if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds { let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" // Clear the shared content diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 02ee7863..9a86ec56 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -142,7 +142,7 @@ final class BLEService: NSObject { private var connectionCandidates: [ConnectionCandidate] = [] private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures private var lastIsolatedAt: Date? = nil - private var dynamicRSSIThreshold: Int = -90 + private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault // MARK: - Adaptive scanning duty-cycle private var scanDutyTimer: DispatchSourceTimer? @@ -469,7 +469,7 @@ final class BLEService: NSObject { } // Give leave message a moment to send - Thread.sleep(forTimeInterval: 0.05) + Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds) // Clear pending notifications collectionsQueue.sync(flags: .barrier) { @@ -965,7 +965,7 @@ final class BLEService: NSObject { if success { sentEncrypted = true; break } collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - if self.pendingNotifications.count < 20 { + if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { self.pendingNotifications.append((data: data, centrals: [central])) SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) } @@ -1096,7 +1096,7 @@ final class BLEService: NSObject { guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } if c.isScanning { c.stopScan() } // Resume scanning after we expect last fragment to be sent - let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment + let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() } @@ -1127,7 +1127,7 @@ final class BLEService: NSObject { ttl: packet.ttl ) // Pace fragments with small jitter to avoid bursts - let delayMs = index * 6 // ~6ms spacing per fragment + let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in self?.broadcastPacket(fragmentPacket) } @@ -1239,10 +1239,10 @@ final class BLEService: NSObject { guard let self = self else { return } let now = Date() self.recentPacketTimestamps.append(now) - // keep last 100 timestamps within 30s window - let cutoff = now.addingTimeInterval(-30) - if self.recentPacketTimestamps.count > 100 { - self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100) + // keep last N timestamps within window + let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) + if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { + self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) } self.recentPacketTimestamps.removeAll { $0 < cutoff } } @@ -1606,7 +1606,7 @@ final class BLEService: NSObject { let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) // Even forced sends should respect a minimum interval to avoid overwhelming BLE - let minInterval = forceSend ? 0.2 : announceMinInterval + let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval if timeSinceLastAnnounce < minInterval { // Skipping announce (rate limited) @@ -1737,12 +1737,14 @@ final class BLEService: NSObject { let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let elapsed = now.timeIntervalSince(lastAnnounceSent) if connectedCount == 0 { - // Discovery mode: keep frequent announces (~10s) - if elapsed >= 10.0 { sendAnnounce(forceSend: true) } + // Discovery mode: keep frequent announces + if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) } } else { // Connected mode: announce less often; much less in dense networks - let base = connectedCount >= 6 ? 90.0 : 45.0 - let jitter = connectedCount >= 6 ? 20.0 : 7.5 + let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ? + TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse + let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ? + TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse let target = base + Double.random(in: -jitter...jitter) if elapsed >= target { sendAnnounce(forceSend: true) } } @@ -1783,7 +1785,7 @@ final class BLEService: NSObject { collectionsQueue.sync(flags: .barrier) { for (peerID, peer) in peers { - if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 { + if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds { // Check if we still have an active BLE connection to this peer let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true @@ -1823,9 +1825,9 @@ final class BLEService: NSObject { // Clean old processed messages efficiently messageDeduplicator.cleanup() - // Clean old fragments (> 30 seconds old) + // Clean old fragments (> configured seconds old) collectionsQueue.sync(flags: .barrier) { - let cutoff = now.addingTimeInterval(-30) + let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) @@ -1833,8 +1835,8 @@ final class BLEService: NSObject { } } - // Clean old connection timeout backoff entries (> 2 minutes) - let timeoutCutoff = now.addingTimeInterval(-120) + // Clean old connection timeout backoff entries (> window) + let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } // Clean up stale scheduled relays that somehow persisted (> 2s) @@ -1848,10 +1850,10 @@ final class BLEService: NSObject { } } - // Clean ingress link records older than 3 seconds + // Clean ingress link records older than configured seconds collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - let cutoff = now.addingTimeInterval(-3) + let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) if !self.ingressByMessageID.isEmpty { self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } } @@ -1875,12 +1877,12 @@ final class BLEService: NSObject { if !central.isScanning { startScanning() } dutyActive = true // Adjust duty cycle under dense networks to save battery - if connectedCount >= 6 { - dutyOnDuration = 3 - dutyOffDuration = 15 + if connectedCount >= TransportConfig.bleHighDegreeThreshold { + dutyOnDuration = TransportConfig.bleDutyOnDurationDense + dutyOffDuration = TransportConfig.bleDutyOffDurationDense } else { - dutyOnDuration = 5 - dutyOffDuration = 10 + dutyOnDuration = TransportConfig.bleDutyOnDuration + dutyOffDuration = TransportConfig.bleDutyOffDuration } t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.setEventHandler { [weak self] in @@ -1986,7 +1988,9 @@ extension BLEService: CBCentralManagerDelegate { if a.rssi != b.rssi { return a.rssi > b.rssi } return a.discoveredAt < b.discoveredAt } - if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } + if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) + } return } @@ -2000,7 +2004,9 @@ extension BLEService: CBCentralManagerDelegate { if a.rssi != b.rssi { return a.rssi > b.rssi } return a.discoveredAt < b.discoveredAt } - if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } + if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax) + } return } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index cb25f964..56a1da75 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -28,8 +28,9 @@ enum TransportConfig { // BLE discovery/quality thresholds static let bleDynamicRSSIThresholdDefault: Int = -90 - static let bleConnectionCandidatesMax: Int = 20 + static let bleConnectionCandidatesMax: Int = 100 static let blePendingWriteBufferCapBytes: Int = 1_000_000 + static let blePendingNotificationsCapCount: Int = 20 // Nostr static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second @@ -37,6 +38,28 @@ enum TransportConfig { // UI thresholds static let uiLateInsertThreshold: TimeInterval = 15.0 static let uiProcessedNostrEventsCap: Int = 2000 + static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 + + // UI rate limiters (token buckets) + static let uiSenderRateBucketCapacity: Double = 5 + static let uiSenderRateBucketRefillPerSec: Double = 1.0 + static let uiContentRateBucketCapacity: Double = 3 + static let uiContentRateBucketRefillPerSec: Double = 0.5 + + // UI sleeps/delays + static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 + static let uiStartupShortSleepNs: UInt64 = 200_000_000 + static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0 + static let uiAsyncShortSleepNs: UInt64 = 100_000_000 + static let uiAsyncMediumSleepNs: UInt64 = 500_000_000 + static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1 + static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5 + static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15 + static let uiScrollThrottleSeconds: TimeInterval = 0.5 + static let uiAnimationShortSeconds: TimeInterval = 0.15 + static let uiAnimationMediumSeconds: TimeInterval = 0.2 + static let uiAnimationSidebarSeconds: TimeInterval = 0.25 + static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 // BLE maintenance & thresholds static let bleMaintenanceInterval: TimeInterval = 10.0 @@ -48,6 +71,23 @@ enum TransportConfig { static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIHighTimeoutThreshold: Int = -80 + static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 + static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 + static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 + static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 + static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 + static let bleRecentPacketWindowMaxCount: Int = 100 + static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 + static let bleExpectedWritePerFragmentMs: Int = 8 + static let bleExpectedWriteMaxMs: Int = 2000 + static let bleFragmentSpacingMs: Int = 6 + static let bleAnnounceIntervalSeconds: TimeInterval = 10.0 + static let bleDutyOnDurationDense: TimeInterval = 3.0 + static let bleDutyOffDurationDense: TimeInterval = 15.0 + static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0 + static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0 + static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0 + static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5 // Location static let locationDistanceFilterMeters: Double = 1000 @@ -57,6 +97,13 @@ enum TransportConfig { static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600 static let nostrGeohashInitialLimit: Int = 200 static let nostrGeoRelayCount: Int = 5 + static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 + static let nostrGeohashSampleLimit: Int = 100 + static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 + + // Nostr helpers + static let nostrShortKeyDisplayLength: Int = 8 + static let nostrConvKeyPrefixLength: Int = 16 // Compression static let compressionThresholdBytes: Int = 100 @@ -84,4 +131,31 @@ enum TransportConfig { static let bleRestartScanDelaySeconds: TimeInterval = 0.1 static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 + static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2 + + // Content hashing / formatting + static let contentKeyPrefixLength: Int = 256 + static let uiLongMessageLengthThreshold: Int = 2000 + static let uiVeryLongTokenThreshold: Int = 512 + static let uiLongMessageLineLimit: Int = 30 + static let uiFingerprintSampleCount: Int = 3 + + // UI swipe/gesture thresholds + static let uiBackSwipeTranslationLarge: CGFloat = 50 + static let uiBackSwipeTranslationSmall: CGFloat = 30 + static let uiBackSwipeVelocityThreshold: CGFloat = 300 + + // UI color tuning + static let uiColorHueAvoidanceDelta: Double = 0.05 + static let uiColorHueOffset: Double = 0.12 + + // UI windowing (infinite scroll) + static let uiWindowInitialCountPublic: Int = 300 + static let uiWindowInitialCountPrivate: Int = 300 + static let uiWindowStepCount: Int = 200 + + // Share extension + static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3 + static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 + static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 6ab387c5..80b089db 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -145,10 +145,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private var rateBucketsBySender: [String: TokenBucket] = [:] private var rateBucketsByContent: [String: TokenBucket] = [:] - private let senderBucketCapacity: Double = 5 - private let senderBucketRefill: Double = 1 // tokens per second - private let contentBucketCapacity: Double = 3 - private let contentBucketRefill: Double = 0.5 // tokens per second + private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity + private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second + private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity + private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second @MainActor private func normalizedSenderKey(for message: BitchatMessage) -> String { @@ -192,7 +192,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) } let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) - let prefix = String(collapsed.prefix(256)) + let prefix = String(collapsed.prefix(TransportConfig.contentKeyPrefixLength)) // Fast djb2 hash let h = djb2(prefix) return String(format: "h:%016llx", h) @@ -393,7 +393,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Channel activity tracking for background nudges private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityNotifyAt: [String: Date] = [:] - private let channelInactivityThreshold: TimeInterval = 9 * 60 + private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds // Geohash participants (per geohash: pubkey -> lastSeen) private var geoParticipants: [String: [String: Date]] = [:] @Published private(set) var geohashPeople: [GeoPerson] = [] @@ -506,7 +506,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Log startup info // Log fingerprint after a delay to ensure encryption service is ready - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in if let self = self { _ = self.getMyFingerprint() } @@ -526,7 +526,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Small delay to ensure read receipts are fully loaded // This prevents race conditions where messages arrive before initialization completes - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs) // 0.2 seconds // Set up Nostr message handling directly setupNostrMessageHandling() @@ -539,7 +539,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // 1. Skip cleanup of read receipts // 2. Only block OLD messages from being marked as unread Task { @MainActor in - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) // 2 seconds self.isStartupPhase = false } @@ -733,9 +733,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.geoNicknames[event.pubkey.lowercased()] = nick } // Store mapping for geohash sender IDs used in messages (ensures consistent colors) - let key16 = "nostr_" + String(event.pubkey.prefix(16)) + let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)) self.nostrKeyMapping[key16] = event.pubkey - let key8 = "nostr:" + String(event.pubkey.prefix(8)) + let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) self.nostrKeyMapping[key8] = event.pubkey // Update participants last-seen for this pubkey @@ -762,7 +762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: nil, isPrivate: false, recipientNickname: nil, - senderPeerID: "nostr:\(event.pubkey.prefix(8))", + senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))", mentions: mentions.isEmpty ? nil : mentions ) Task { @MainActor in @@ -777,7 +777,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in guard let self = self else { return } if self.processedNostrEvents.contains(giftWrap.id) { return } @@ -789,7 +789,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard packet.type == MessageType.noiseEncrypted.rawValue else { return } guard let noisePayload = NoisePayload.decode(packet.payload) else { return } let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - let convKey = "nostr_" + String(senderPubkey.prefix(16)) + let convKey = "nostr_" + String(senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)) self.nostrKeyMapping[convKey] = senderPubkey switch noisePayload.type { case .privateMessage: @@ -1089,7 +1089,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { chatFingerprint == fingerprintStr { // Send read receipts for any unread messages from this peer // Use a small delay to ensure the connection is fully established - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { [weak self] in self?.markPrivateMessagesAsRead(from: peerID) } } @@ -1215,7 +1215,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { let suffix = String(myGeoIdentity.publicKeyHex.suffix(4)) displaySender = nickname + "#" + suffix - localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))" + localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))" } let message = BitchatMessage( @@ -1356,7 +1356,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID startGeoParticipantsTimer() - let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) + let filter = NostrFilter.geohashEphemeral( + ch.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } @@ -1398,9 +1402,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return } // Store mapping for geohash DM initiation - let key16 = "nostr_" + String(event.pubkey.prefix(16)) + let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)) self.nostrKeyMapping[key16] = event.pubkey - let key8 = "nostr:" + String(event.pubkey.prefix(8)) + let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength)) self.nostrKeyMapping[key8] = event.pubkey // Update participants last-seen for this pubkey self.recordGeoParticipant(pubkeyHex: event.pubkey) @@ -1423,7 +1427,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: nil, isPrivate: false, recipientNickname: nil, - senderPeerID: "nostr:\(event.pubkey.prefix(8))", + senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))", mentions: mentions.isEmpty ? nil : mentions ) Task { @MainActor in @@ -1441,7 +1445,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // pared back logging: subscribe debug only SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: SecureLogger.session, level: .debug) - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in guard let self = self else { return } // Dedup basic @@ -1590,7 +1594,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private func refreshGeohashPeople() { guard let gh = currentGeohash else { geohashPeople = []; return } - let cutoff = Date().addingTimeInterval(-5 * 60) + let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) var map = geoParticipants[gh] ?? [:] // Prune expired entries map = map.filter { $0.value >= cutoff } @@ -1625,7 +1629,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func visibleGeohashPeople() -> [GeoPerson] { guard let gh = currentGeohash else { return [] } - let cutoff = Date().addingTimeInterval(-5 * 60) + let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let map = (geoParticipants[gh] ?? [:]) .filter { $0.value >= cutoff } .filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } @@ -1637,7 +1641,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { - let cutoff = Date().addingTimeInterval(-5 * 60) + let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let map = geoParticipants[geohash] ?? [:] return map.values.filter { $0 >= cutoff }.count } @@ -1685,7 +1689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Remove geohash DM conversation if exists - let convKey = "nostr_" + String(hex.prefix(16)) + let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) if privateChats[convKey] != nil { privateChats.removeValue(forKey: convKey) unreadPrivateMessages.remove(convKey) @@ -1721,7 +1725,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { for gh in toAdd { let subID = "geo-sample-\(gh)" geoSamplingSubs[subID] = gh - let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100) + 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 guard let self = self else { return } @@ -1924,7 +1932,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Geohash DMs initiation @MainActor func startGeohashDM(withPubkeyHex hex: String) { - let convKey = "nostr_" + String(hex.prefix(16)) + let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) nostrKeyMapping[convKey] = hex selectedPrivateChatPeer = convKey } @@ -2394,7 +2402,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Try immediately self.markPrivateMessagesAsRead(from: peerID) // And again with a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { self.markPrivateMessagesAsRead(from: peerID) } } @@ -2673,7 +2681,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }().lowercased() // Try exact match against cached geoNicknames (pubkey -> nickname) if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { - let convKey = "nostr_" + String(pub.prefix(16)) + let convKey = "nostr_" + String(pub.prefix(TransportConfig.nostrConvKeyPrefixLength)) nostrKeyMapping[convKey] = pub return convKey } @@ -2758,7 +2766,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // This will generate new Nostr keys derived from new Noise keys Task { @MainActor in // Small delay to ensure cleanup completes - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds // Reinitialize Nostr relay manager with new identity nostrRelayManager = NostrRelayManager() @@ -2928,7 +2936,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { - return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))" + return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))" } } return spid == meshService.myPeerID @@ -3479,7 +3487,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var hue = Double(djb2(seed) % 360) / 360.0 // Avoid orange (~30°) reserved for self let orange = 30.0 / 360.0 - if abs(hue - orange) < 0.05 { hue = fmod(hue + 0.12, 1.0) } + if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta { + hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0) + } let saturation: Double = isDark ? 0.80 : 0.70 let brightness: Double = isDark ? 0.75 : 0.45 let c = Color(hue: hue, saturation: saturation, brightness: brightness) @@ -3689,7 +3699,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Load verified fingerprints directly from secure storage verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() // Log snapshot for debugging persistence - let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ") + let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info) // Also log any offline favorites and whether we consider them verified let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } @@ -3987,7 +3997,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey), favoriteStatus.isFavorite { // Resend favorite notification with our Nostr key after a short delay - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds meshService.sendFavoriteNotification(to: peerID, isFavorite: true) SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)", category: SecureLogger.session, level: .debug) @@ -4401,7 +4411,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Subscribe to Nostr messages let filter = NostrFilter.giftWrapsFor( pubkey: currentIdentity.publicKeyHex, - since: Date().addingTimeInterval(-86400) // Last 24 hours + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours ) nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in @@ -4472,7 +4482,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown" // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer - let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(16)) + let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)) switch noisePayload.type { case .privateMessage: @@ -4795,7 +4805,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // For now, create a temporary peer ID based on Nostr pubkey // This allows the message to be displayed even without Noise key mapping - let tempPeerID = "nostr_" + senderPubkey.prefix(16) + let tempPeerID = "nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength) // Check if we're viewing this unknown sender's chat let isViewingThisChat = selectedPrivateChatPeer == tempPeerID @@ -5023,7 +5033,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var oldPeerIDsToRemove: [String] = [] // Only migrate messages from the last 24 hours to prevent old messages from flooding - let cutoffTime = Date().addingTimeInterval(-24 * 60 * 60) + let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) for (oldPeerID, messages) in privateChats { if oldPeerID != peerID { @@ -5247,7 +5257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Mark other messages as read - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in self?.markPrivateMessagesAsRead(from: peerID) } } @@ -5483,7 +5493,7 @@ private func checkForMentions(_ message: BitchatMessage) { impactFeedback.prepare() for i in 0..<8 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) { impactFeedback.impactOccurred() } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e630be50..0940bdd4 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -100,14 +100,14 @@ struct ContentView: View { .onEnded { value in let translation = value.translation.width.isNaN ? 0 : value.translation.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width - if translation > 50 || (translation > 30 && velocity > 300) { - withAnimation(.easeOut(duration: 0.2)) { + if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) { + withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showPrivateChat = false backSwipeOffset = 0 viewModel.endPrivateChat() } } else { - withAnimation(.easeOut(duration: 0.15)) { + withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) { backSwipeOffset = 0 } } @@ -121,7 +121,7 @@ struct ContentView: View { Color.clear .contentShape(Rectangle()) .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = false sidebarDragOffset = 0 } @@ -151,14 +151,14 @@ struct ContentView: View { let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width) return showSidebar ? -dragOffset : width - dragOffset }()) - .animation(.easeInOut(duration: 0.25), value: showSidebar) + .animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar) } } #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showPrivateChat = newValue != nil } } @@ -195,7 +195,7 @@ struct ContentView: View { } else { viewModel.startPrivateChat(with: peerID) } - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = false sidebarDragOffset = 0 } @@ -264,7 +264,7 @@ struct ContentView: View { // Implement windowing with adjustable window count per chat let currentWindowCount: Int = { - if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 } + if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate } return windowCountPublic }() let windowedMessages = messages.suffix(currentWindowCount) @@ -296,11 +296,11 @@ struct ContentView: View { let cashuTokens = message.content.extractCashuTokens() let lightningLinks = message.content.extractLightningLinks() HStack(alignment: .top, spacing: 0) { - let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty + let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) - .lineLimit(isLong && !isExpanded ? 30 : nil) + .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) // Delivery status indicator for private messages @@ -312,7 +312,7 @@ struct ContentView: View { } // Expand/Collapse for very long messages - if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty { + if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty { let isExpanded = expandedMessageIDs.contains(message.id) Button(isExpanded ? "show less" : "show more") { if isExpanded { expandedMessageIDs.remove(message.id) } @@ -371,7 +371,7 @@ struct ContentView: View { } // Infinite scroll up: when top row appears, increase window and preserve anchor if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { - let step = 200 + let step = TransportConfig.uiWindowStepCount let contextKey: String = { if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { @@ -381,7 +381,7 @@ struct ContentView: View { }() let preserveID = "\(contextKey)|\(message.id)" if let peer = privatePeer { - let current = windowCountPrivate[peer] ?? 300 + let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate let newCount = min(messages.count, current + step) if newCount != current { windowCountPrivate[peer] = newCount @@ -540,7 +540,7 @@ struct ContentView: View { } // Throttle scroll animations to prevent excessive UI updates let now = Date() - if now.timeIntervalSince(lastScrollTime) > 0.5 { + if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { // Immediate scroll if enough time has passed lastScrollTime = now let contextKey: String = { @@ -557,7 +557,7 @@ struct ContentView: View { } else { // Schedule a delayed scroll scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in lastScrollTime = Date() let contextKey: String = { switch locationManager.selectedChannel { @@ -589,7 +589,7 @@ struct ContentView: View { } // Same throttling for private chats let now = Date() - if now.timeIntervalSince(lastScrollTime) > 0.5 { + if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { lastScrollTime = now let contextKey = "dm:\(peerID)" let count = windowCountPrivate[peerID] ?? 300 @@ -599,7 +599,7 @@ struct ContentView: View { } } else { scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in lastScrollTime = Date() let contextKey = "dm:\(peerID)" let count = windowCountPrivate[peerID] ?? 300 @@ -619,7 +619,7 @@ struct ContentView: View { break case .location(let ch): // Reset window size - windowCountPublic = 300 + windowCountPublic = TransportConfig.uiWindowInitialCountPublic let contextKey = "geo:\(ch.geohash)" let last = viewModel.messages.suffix(windowCountPublic).last?.id let target = last.map { "\(contextKey)|\($0)" } @@ -635,11 +635,11 @@ struct ContentView: View { // Try multiple times to ensure read receipts are sent viewModel.markPrivateMessagesAsRead(from: peerID) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { viewModel.markPrivateMessagesAsRead(from: peerID) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { viewModel.markPrivateMessagesAsRead(from: peerID) } } @@ -859,7 +859,7 @@ struct ContentView: View { } .onAppear { // Delay keyboard focus to avoid iOS constraint warnings - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { isTextFieldFocused = true } } @@ -925,7 +925,7 @@ struct ContentView: View { secondaryTextColor: secondaryTextColor, onTapPeer: { peerID in viewModel.startPrivateChat(with: peerID) - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar = false sidebarDragOffset = 0 } @@ -973,7 +973,7 @@ struct ContentView: View { .onEnded { value in let translation = value.translation.width.isNaN ? 0 : value.translation.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) { if !showSidebar { if translation < -100 || (translation < -50 && velocity < -500) { showSidebar = true @@ -1153,7 +1153,7 @@ struct ContentView: View { // QR moved to the PEOPLE header in the sidebar when on mesh channel } .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar.toggle() sidebarDragOffset = 0 } @@ -1294,7 +1294,7 @@ struct ContentView: View { // Left and right buttons positioned with HStack HStack { Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showPrivateChat = false viewModel.endPrivateChat() } diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index 0f9edd8b..d41b3543 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -159,7 +159,7 @@ final class ShareViewController: UIViewController { private func finishWithMessage(_ msg: String) { statusLabel.text = msg // Complete shortly after showing status - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) } }