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