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")
// Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
private let maxMessageLength = 10_000
private let messageTTL: UInt8 = 7
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections)
@@ -257,20 +257,15 @@ final class BLEService: NSObject {
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync {
// Compute nickname collision counts for connected peers
let connected = peers.values.filter { $0.isConnected }
var counts: [String: Int] = [:]
for p in connected { counts[p.nickname, default: 0] += 1 }
// Include our own nickname in collision counts so remote matching ours gets suffixed
counts[myNickname, default: 0] += 1
return peers.values.map { info in
var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4))
}
return TransportPeerSnapshot(
let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.id, $0.nickname, $0.isConnected) },
selfNickname: myNickname
)
return snapshot.map { info in
TransportPeerSnapshot(
id: info.id,
nickname: display,
nickname: resolvedNames[info.id] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
@@ -514,22 +509,9 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [String: String] {
return collectionsQueue.sync {
// Only connected peers
let connected = peers.filter { $0.value.isConnected }
// Count collisions by nickname (include our own nickname)
var counts: [String: Int] = [:]
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
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
}
// Cap for messages stored per private chat
private let privateChatCap = 1337
private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer
func startChat(with peerID: String) {
+1 -1
View File
@@ -3,7 +3,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot {
struct TransportPeerSnapshot: Equatable, Hashable {
let id: String
let nickname: String
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
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private let contentLRUCap = 2000
private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
@@ -219,13 +219,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var messages: [BitchatMessage] = []
@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
private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = []
private var lastNetworkNotificationTime = Date.distantPast
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 = "" {
didSet {
// Trim whitespace whenever nickname is set
@@ -386,10 +386,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = []
private let meshTimelineCap = 1337
private let meshTimelineCap = TransportConfig.meshTimelineCap
// Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = 1337
private let geoTimelineCap = TransportConfig.geoTimelineCap
// Channel activity tracking for background nudges
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
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
private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
private var dynamicPublicFlushInterval: TimeInterval = 0.08
private let basePublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = 15.0
@@ -441,21 +441,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Only persist if there are changes
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)) {
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 {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)