docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize)

This commit is contained in:
jack
2025-08-25 18:48:02 +02:00
parent 680a390b2d
commit 58c516075c
6 changed files with 76 additions and 55 deletions
+15 -33
View File
@@ -22,12 +22,12 @@ final class BLEService: NSObject {
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
// Default per-fragment chunk size when link limits are unknown // Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let maxMessageLength = 10_000 private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = 7 private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls // Flood/battery controls
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections) // MARK: - Core State (5 Essential Collections)
@@ -257,20 +257,15 @@ final class BLEService: NSObject {
func currentPeerSnapshots() -> [TransportPeerSnapshot] { func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync { collectionsQueue.sync {
// Compute nickname collision counts for connected peers let snapshot = Array(peers.values)
let connected = peers.values.filter { $0.isConnected } let resolvedNames = PeerDisplayNameResolver.resolve(
var counts: [String: Int] = [:] snapshot.map { ($0.id, $0.nickname, $0.isConnected) },
for p in connected { counts[p.nickname, default: 0] += 1 } selfNickname: myNickname
// Include our own nickname in collision counts so remote matching ours gets suffixed )
counts[myNickname, default: 0] += 1 return snapshot.map { info in
return peers.values.map { info in TransportPeerSnapshot(
var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4))
}
return TransportPeerSnapshot(
id: info.id, id: info.id,
nickname: display, nickname: resolvedNames[info.id] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen
@@ -514,22 +509,9 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [String: String] { func getPeerNicknames() -> [String: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
// Only connected peers
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
// Count collisions by nickname (include our own nickname) let tuples = connected.map { ($0.key, $0.value.nickname, true) }
var counts: [String: Int] = [:] return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
for (_, info) in connected { counts[info.nickname, default: 0] += 1 }
counts[myNickname, default: 0] += 1
// Build map with suffix for collisions
var result: [String: String] = [:]
for (id, info) in connected {
var name = info.nickname
if (counts[info.nickname] ?? 0) > 1 {
name += "#" + String(id.prefix(4))
}
result[id] = name
}
return result
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = 1337 private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: String) {
+1 -1
View File
@@ -3,7 +3,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let id: String
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
+22
View File
@@ -0,0 +1,22 @@
import Foundation
/// Centralized knobs for transport- and UI-related limits.
/// Keep values aligned with existing behavior when replacing magic numbers.
enum TransportConfig {
// BLE / Protocol
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
}
@@ -0,0 +1,29 @@
import Foundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters:
/// - peers: Array of tuples (id, nickname, isConnected).
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName.
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
// Count collisions among connected peers and include our own nickname
var counts: [String: Int] = [:]
for p in peers where p.isConnected {
counts[p.nickname, default: 0] += 1
}
counts[selfNickname, default: 0] += 1
var result: [String: String] = [:]
for p in peers {
var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
name += "#" + String(p.id.prefix(4))
}
result[p.id] = name
}
return result
}
}
+8 -20
View File
@@ -201,7 +201,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Persistent recent content map (LRU) to speed near-duplicate checks // Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:] private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = [] private var contentLRUOrder: [String] = []
private let contentLRUCap = 2000 private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) { private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) } if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp contentLRUMap[key] = timestamp
@@ -219,13 +219,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var messages: [BitchatMessage] = [] @Published var messages: [BitchatMessage] = []
@Published var currentColorScheme: ColorScheme = .light @Published var currentColorScheme: ColorScheme = .light
private let maxMessages = 1337 // Maximum messages before oldest are removed private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false @Published var isConnected = false
private var hasNotifiedNetworkAvailable = false private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = [] private var recentlySeenPeers: Set<String> = []
private var lastNetworkNotificationTime = Date.distantPast private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? = nil private var networkResetTimer: Timer? = nil
private let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes; avoid refiring on short drops/reconnects private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" { @Published var nickname: String = "" {
didSet { didSet {
// Trim whitespace whenever nickname is set // Trim whitespace whenever nickname is set
@@ -386,10 +386,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Messages are naturally ephemeral - no persistent storage // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = [] private var meshTimeline: [BitchatMessage] = []
private let meshTimelineCap = 1337 private let meshTimelineCap = TransportConfig.meshTimelineCap
// Persist per-geohash public timelines across switches // Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = 1337 private let geoTimelineCap = TransportConfig.geoTimelineCap
// Channel activity tracking for background nudges // Channel activity tracking for background nudges
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:] private var lastPublicActivityNotifyAt: [String: Date] = [:]
@@ -428,8 +428,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Buffer incoming public messages and flush in small batches to reduce UI invalidations // Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = [] private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil private var publicBufferTimer: Timer? = nil
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching private let basePublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var dynamicPublicFlushInterval: TimeInterval = 0.08 private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var recentBatchSizes: [Int] = [] private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false @Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = 15.0 private let lateInsertThreshold: TimeInterval = 15.0
@@ -441,21 +441,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Only persist if there are changes // Only persist if there are changes
guard oldValue != sentReadReceipts else { return } guard oldValue != sentReadReceipts else { return }
// Persist to UserDefaults whenever it changes // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts") UserDefaults.standard.set(data, forKey: "sentReadReceipts")
// Force synchronization for immediate persistence (ensures data is written to disk)
UserDefaults.standard.synchronize()
// Verify persistence by re-reading
if let verifyData = UserDefaults.standard.data(forKey: "sentReadReceipts"),
let _ = try? JSONDecoder().decode([String].self, from: verifyData) {
// Only log errors, not successful persistence
// Successfully persisted
} else {
SecureLogger.log("⚠️ Failed to verify persistence of read receipts",
category: SecureLogger.session, level: .error)
}
} else { } else {
SecureLogger.log("❌ Failed to encode read receipts for persistence", SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error) category: SecureLogger.session, level: .error)