From a7d5b2d7d9e3b8b11772a74731da6240f0753e4c Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:18:51 +0200 Subject: [PATCH] Centralize remaining magic numbers; finalize logging hygiene across UI/BLE/Nostr (#522) * 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) * project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen) * chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM) * project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources * chore(BLE): move connect/duty/announce constants to TransportConfig and reference them * chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication * chore(BLE): trim raw hex dump on central decode failure to length+prefix * project: remove duplicate TransportConfig.swift entries from Sources build phases * chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport * chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel * chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules * chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays * 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. * project: add TransportConfig.swift to Share Extension target to fix build --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 40 +++-- bitchat/BitchatApp.swift | 6 +- bitchat/Nostr/GeoRelayDirectory.swift | 2 +- bitchat/Nostr/NostrRelayManager.swift | 18 +- bitchat/Services/BLEService.swift | 161 +++++++++--------- bitchat/Services/LocationChannelManager.swift | 4 +- bitchat/Services/NostrTransport.swift | 59 +++---- bitchat/Services/PrivateChatManager.swift | 2 +- bitchat/Services/Transport.swift | 2 +- bitchat/Services/TransportConfig.swift | 161 ++++++++++++++++++ bitchat/Services/VerificationService.swift | 2 +- bitchat/Utils/CompressionUtil.swift | 2 +- bitchat/Utils/MessageDeduplicator.swift | 5 +- bitchat/Utils/PeerDisplayNameResolver.swift | 29 ++++ bitchat/ViewModels/ChatViewModel.swift | 160 ++++++++--------- bitchat/Views/ContentView.swift | 50 +++--- .../ShareViewController.swift | 4 +- 17 files changed, 442 insertions(+), 265 deletions(-) create mode 100644 bitchat/Services/TransportConfig.swift create mode 100644 bitchat/Utils/PeerDisplayNameResolver.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 37f1f683..41f0019b 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; }; 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; }; 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; }; - 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; }; 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; }; 047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; }; @@ -30,6 +29,9 @@ 047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; }; 047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; + 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; + 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; + 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; @@ -57,6 +59,8 @@ 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; + 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; + 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; }; 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; @@ -103,11 +107,17 @@ 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; }; 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; + A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; + A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; + AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; + AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; + AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; + AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; @@ -141,12 +151,6 @@ F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; - A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; - A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; - AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; - AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; - AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; - AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -200,6 +204,7 @@ 047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = ""; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = ""; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = ""; }; + 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = ""; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = ""; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = ""; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = ""; }; @@ -215,6 +220,7 @@ 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = ""; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = ""; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = ""; }; + 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = ""; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; @@ -245,9 +251,11 @@ 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = ""; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; - AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = ""; }; + AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = ""; }; + AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = ""; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -266,9 +274,6 @@ FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; - A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = ""; }; - AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = ""; }; - AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -323,6 +328,7 @@ A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, + 048A4BE52E5CCC5C00162C4A /* Recovered References */, ); sourceTree = ""; }; @@ -405,6 +411,7 @@ 9A78348821A7D3374607D4E3 /* Utils */ = { isa = PBXGroup; children = ( + 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */, 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */, 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */, @@ -443,7 +450,6 @@ 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */, - 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, @@ -514,6 +520,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, 047502B82E560F690083520F /* RelayController.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */, @@ -727,6 +734,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */, 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -735,11 +743,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, + 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, + 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, @@ -758,7 +769,6 @@ 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, - 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, @@ -793,11 +803,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, + 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, + 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, @@ -816,7 +829,6 @@ EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, - 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 6650ee40..dddf4588 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -99,15 +99,15 @@ 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 userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentDate") - userDefaults.synchronize() + // No need to force synchronize here // Send the shared content immediately on the main queue DispatchQueue.main.async { diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index 8151f952..15e73dd2 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -14,7 +14,7 @@ final class GeoRelayDirectory { private let cacheFileName = "georelays_cache.csv" private let lastFetchKey = "georelay.lastFetchAt" private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! - private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h + private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h private init() { // Load cached or bundled data synchronously diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index d4734eee..3c894d4e 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject { private let messageQueueLock = NSLock() // Exponential backoff configuration - private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second - private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes - private let backoffMultiplier: Double = 2.0 // Double each time - private let maxReconnectAttempts = 10 // Stop after 10 attempts + private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds + private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds + private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier + private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts // Reconnection timer private var reconnectionTimer: Timer? @@ -289,7 +289,7 @@ class NostrRelayManager: ObservableObject { // Only log non-gift-wrap events to reduce noise if event.kind != 1059 { - SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)", + SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: SecureLogger.session, level: .debug) } @@ -321,11 +321,11 @@ class NostrRelayManager: ObservableObject { let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" if success { _ = Self.pendingGiftWrapIDs.remove(eventId) - SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)", + SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: SecureLogger.session, level: .debug) } else { let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil - SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)", + SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: SecureLogger.session, level: isGiftWrap ? .warning : .error) } } @@ -353,7 +353,7 @@ class NostrRelayManager: ObservableObject { let data = try encoder.encode(req) let message = String(data: data, encoding: .utf8) ?? "" - SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)", + SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: SecureLogger.session, level: .debug) connection.send(.string(message)) { [weak self] error in @@ -568,7 +568,7 @@ struct NostrFilter: Encodable { filter.kinds = [1059] // Gift wrap kind filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["p": [pubkey]] - filter.limit = 100 // Add a reasonable limit + filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit return filter } diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 0110bf08..9a86ec56 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -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) @@ -70,7 +70,7 @@ final class BLEService: NSObject { // Simple announce throttling private var lastAnnounceSent = Date.distantPast - private let announceMinInterval: TimeInterval = 1.0 + private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval // Application state tracking (thread-safe) #if os(iOS) @@ -129,8 +129,8 @@ final class BLEService: NSObject { private var maintenanceCounter = 0 // Track maintenance cycles // MARK: - Connection budget & scheduling (central role) - private let maxCentralLinks = 6 - private let connectRateLimitInterval: TimeInterval = 0.5 + private let maxCentralLinks = TransportConfig.bleMaxCentralLinks + private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval private var lastGlobalConnectAttempt: Date = .distantPast private struct ConnectionCandidate { let peripheral: CBPeripheral @@ -142,13 +142,13 @@ 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? private var dutyEnabled: Bool = true - private var dutyOnDuration: TimeInterval = 5 - private var dutyOffDuration: TimeInterval = 10 + private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration + private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration private var dutyActive: Bool = false // MARK: - Link capability snapshots (thread-safe via bleQueue) @@ -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 @@ -351,7 +346,9 @@ final class BLEService: NSObject { // Single maintenance timer for all periodic tasks (dispatch-based for determinism) let timer = DispatchSource.makeTimerSource(queue: bleQueue) - timer.schedule(deadline: .now() + 10.0, repeating: 10.0, leeway: .seconds(1)) + timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, + repeating: TransportConfig.bleMaintenanceInterval, + leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds)) timer.setEventHandler { [weak self] in self?.performMaintenance() } @@ -433,7 +430,7 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces - messageQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in + messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) } } @@ -472,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) { @@ -514,22 +511,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) } } @@ -981,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) } @@ -1112,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() } @@ -1143,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) } @@ -1255,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 } } @@ -1622,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) @@ -1753,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) } } @@ -1799,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 @@ -1839,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) @@ -1849,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) @@ -1864,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 } } @@ -1891,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 @@ -1930,25 +1916,25 @@ final class BLEService: NSObject { if lastIsolatedAt == nil { lastIsolatedAt = Date() } let iso = lastIsolatedAt ?? Date() let elapsed = Date().timeIntervalSince(iso) - if elapsed > 60 { - dynamicRSSIThreshold = -92 + if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds { + dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed } else { - dynamicRSSIThreshold = -90 + dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase } return } lastIsolatedAt = nil // Base threshold when connected - var threshold = -90 + var threshold = TransportConfig.bleDynamicRSSIThresholdDefault // If we're at budget or queue is large, prefer closer peers let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count - if linkCount >= maxCentralLinks || connectionCandidates.count > 20 { - threshold = -85 + if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax { + threshold = TransportConfig.bleRSSIConnectedThreshold } // If we have many recent timeouts, raise further - let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count - if recentTimeouts >= 3 { - threshold = max(threshold, -80) + let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count + if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold { + threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold) } dynamicRSSIThreshold = threshold } @@ -2002,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 } @@ -2016,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 } @@ -2093,7 +2083,7 @@ extension BLEService: CBCentralManagerDelegate { // Set a timeout for the connection attempt (slightly longer for reliability) // Use BLE queue to mutate BLE-related state consistently - bleQueue.asyncAfter(deadline: .now() + 8.0) { [weak self] in + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in guard let self = self, let state = self.peripherals[peripheralID], state.isConnecting && !state.isConnected else { return } @@ -2165,7 +2155,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph if centralManager?.state == .poweredOn { // Stop and restart scanning to ensure we get fresh discovery events centralManager?.stopScan() - bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in + bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in self?.startScanning() } } @@ -2363,7 +2353,7 @@ extension BLEService: CBPeripheralDelegate { SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) // Send announce after subscription is confirmed (force send for new connection) - messageQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) } } else { @@ -2386,7 +2376,9 @@ extension BLEService: CBPeripheralDelegate { // Process directly on main thread to avoid deadlocks (matches original implementation) guard let packet = BinaryProtocol.decode(data) else { - SecureLogger.log("❌ Failed to decode notification packet, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))", + // Avoid dumping entire payload; log size and short prefix for diagnostics + let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: SecureLogger.session, level: .error) return } @@ -2523,7 +2515,7 @@ extension BLEService: CBPeripheralManagerDelegate { SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) subscribedCentrals.append(central) // Send announce to the newly subscribed central after a small delay to avoid overwhelming - messageQueue.asyncAfter(deadline: .now() + 0.4) { [weak self] in + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) } } @@ -2682,13 +2674,14 @@ extension BLEService: CBPeripheralManagerDelegate { } } else { // If buffer grows suspiciously large, reset to avoid memory leak - if combined.count > 1_000_000 { // 1MB cap for safety + if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety pendingWriteBuffers.removeValue(forKey: centralUUID) SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) } // If this was a single short write and still failed, log the raw chunk for debugging if !hasMultiple, let only = sorted.first, let raw = only.value { - SecureLogger.log("❌ Failed to decode packet from central, full data: \(raw.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error) + let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error) } } } diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index e33d9671..64e992a5 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa super.init() cl.delegate = self cl.desiredAccuracy = kCLLocationAccuracyHundredMeters - cl.distanceFilter = 1000 // meters; we're not tracking continuously + cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously // Load selection if let data = UserDefaults.standard.data(forKey: userDefaultsKey), let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { @@ -93,7 +93,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } /// Begin periodic one-shot location refreshes while a selector UI is visible. - func beginLiveRefresh(interval: TimeInterval = 5.0) { + func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { guard permissionState == .authorized else { return } // Switch to a lightweight periodic one-shot request (polling) while the sheet is open refreshTimer?.invalidate() diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 7e42886d..b19a6924 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -20,7 +20,7 @@ final class NostrTransport: Transport { } private var readQueue: [QueuedRead] = [] private var isSendingReadAcks = false - private let readAckInterval: TimeInterval = 0.35 // ~3 per second + private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval var myPeerID: String { senderPeerID } var myNickname: String { "" } @@ -48,16 +48,7 @@ final class NostrTransport: Transport { func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { Task { @MainActor in - // Resolve favorite by full noise key or by short peerID fallback - var recipientNostrPubkey: String? - if let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { - recipientNostrPubkey = fav.peerNostrPublicKey - } - if recipientNostrPubkey == nil, peerID.count == 16 { - recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey - } - guard let recipientNpub = recipientNostrPubkey else { return } + guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: SecureLogger.session, level: .debug) @@ -105,15 +96,7 @@ final class NostrTransport: Transport { guard !readQueue.isEmpty else { isSendingReadAcks = false; return } let item = readQueue.removeFirst() Task { @MainActor in - var recipientNostrPubkey: String? - if let noiseKey = Data(hexString: item.peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { - recipientNostrPubkey = fav.peerNostrPublicKey - } - if recipientNostrPubkey == nil, item.peerID.count == 16 { - recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey - } - guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return } + guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: SecureLogger.session, level: .debug) @@ -149,15 +132,7 @@ final class NostrTransport: Transport { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { Task { @MainActor in - var recipientNostrPubkey: String? - if let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { - recipientNostrPubkey = fav.peerNostrPublicKey - } - if recipientNostrPubkey == nil, peerID.count == 16 { - recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey - } - guard let recipientNpub = recipientNostrPubkey else { return } + guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", @@ -183,18 +158,26 @@ final class NostrTransport: Transport { } } + // MARK: - Helpers + @MainActor + private func resolveRecipientNpub(for peerID: String) -> String? { + if let noiseKey = Data(hexString: peerID), + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + let npub = fav.peerNostrPublicKey { + return npub + } + if peerID.count == 16, + let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + let npub = fav.peerNostrPublicKey { + return npub + } + return nil + } + func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: String) { Task { @MainActor in - var recipientNostrPubkey: String? - if let noiseKey = Data(hexString: peerID), - let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { - recipientNostrPubkey = fav.peerNostrPublicKey - } - if recipientNostrPubkey == nil, peerID.count == 16 { - recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey - } - guard let recipientNpub = recipientNostrPubkey else { return } + guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: SecureLogger.session, level: .debug) diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 0bddc6c0..896e711e 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -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) { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 74686d8a..372e3e3b 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -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 diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift new file mode 100644 index 00000000..56a1da75 --- /dev/null +++ b/bitchat/Services/TransportConfig.swift @@ -0,0 +1,161 @@ +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 + + // BLE duty/announce/connect + static let bleConnectRateLimitInterval: TimeInterval = 0.5 + static let bleMaxCentralLinks: Int = 6 + static let bleDutyOnDuration: TimeInterval = 5.0 + static let bleDutyOffDuration: TimeInterval = 10.0 + static let bleAnnounceMinInterval: TimeInterval = 1.0 + + // BLE discovery/quality thresholds + static let bleDynamicRSSIThresholdDefault: Int = -90 + 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 + + // 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 + static let bleMaintenanceLeewaySeconds: Int = 1 + static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 + static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 + static let bleRecentTimeoutCountThreshold: Int = 3 + static let bleRSSIIsolatedBase: Int = -90 + 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 + static let locationLiveRefreshInterval: TimeInterval = 5.0 + + // Nostr geohash + 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 + + // Message deduplication + static let messageDedupMaxAgeSeconds: TimeInterval = 300 + static let messageDedupMaxCount: Int = 1000 + + // Verification QR + static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60 + + // Nostr relay backoff + static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0 + static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0 + static let nostrRelayBackoffMultiplier: Double = 2.0 + static let nostrRelayMaxReconnectAttempts: Int = 10 + static let nostrRelayDefaultFetchLimit: Int = 100 + + // Geo relay directory + static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 + + // BLE operational delays + static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0 + static let bleConnectTimeoutSeconds: TimeInterval = 8.0 + 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/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index 56b4caeb..2a69fbb7 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -102,7 +102,7 @@ final class VerificationService { } /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) - func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? { + func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? { guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } // Freshness let now = Date().timeIntervalSince1970 diff --git a/bitchat/Utils/CompressionUtil.swift b/bitchat/Utils/CompressionUtil.swift index ccbbb0d9..883fdaa8 100644 --- a/bitchat/Utils/CompressionUtil.swift +++ b/bitchat/Utils/CompressionUtil.swift @@ -11,7 +11,7 @@ import Compression struct CompressionUtil { // Compression threshold - don't compress if data is smaller than this - static let compressionThreshold = 100 // bytes + static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes // Compress data using zlib algorithm (most compatible) static func compress(_ data: Data) -> Data? { diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index c5fc701a..6c067466 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -11,8 +11,8 @@ final class MessageDeduplicator { private var entries: [Entry] = [] private var lookup = Set() private let lock = NSLock() - private let maxAge: TimeInterval = 300 // 5 minutes - private let maxCount = 1000 + private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes + private let maxCount = TransportConfig.messageDedupMaxCount /// Check if message is duplicate and add if not func isDuplicate(_ messageID: String) -> Bool { @@ -84,4 +84,3 @@ final class MessageDeduplicator { } } } - diff --git a/bitchat/Utils/PeerDisplayNameResolver.swift b/bitchat/Utils/PeerDisplayNameResolver.swift new file mode 100644 index 00000000..5af5c417 --- /dev/null +++ b/bitchat/Utils/PeerDisplayNameResolver.swift @@ -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 + } +} + diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 20dee000..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) @@ -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 = [] 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 @@ -349,7 +349,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // PeerManager replaced by UnifiedPeerService private var processedNostrEvents = Set() // Simple deduplication private var processedNostrEventOrder: [String] = [] - private let maxProcessedNostrEvents = 2000 + private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) @@ -386,14 +386,14 @@ 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] = [:] - 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] = [] @@ -428,11 +428,11 @@ 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 + private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -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) @@ -518,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() } @@ -538,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() @@ -551,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 } @@ -703,8 +691,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Deinitialization deinit { - // Force immediate save - userDefaults.synchronize() + // No need to force UserDefaults synchronization } // Resubscribe to the active geohash channel without clearing timeline @@ -720,8 +707,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { startGeoParticipantsTimer() // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + let filter = NostrFilter.geohashEphemeral( + ch.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: ch.geohash, + count: TransportConfig.nostrGeoRelayCount + ) NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } @@ -739,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 @@ -768,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 @@ -783,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 } @@ -795,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: @@ -903,7 +897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func saveNickname() { userDefaults.set(nickname, forKey: nicknameKey) - userDefaults.synchronize() // Force immediate save + // Persist nickname; no need to force synchronize // Send announce with new nickname to all peers meshService.sendBroadcastAnnounce() @@ -1095,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) } } @@ -1221,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( @@ -1277,7 +1271,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: ch.geohash, + count: TransportConfig.nostrGeoRelayCount + ) if targetRelays.isEmpty { SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) } else { @@ -1359,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 } @@ -1401,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) @@ -1426,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 @@ -1444,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 @@ -1593,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 } @@ -1628,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) } @@ -1640,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 } @@ -1688,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) @@ -1724,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 } @@ -1927,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 } @@ -2397,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) } } @@ -2463,12 +2468,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) if targetRelays.isEmpty { - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) - if targetRelays.isEmpty { - SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } + SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) } else { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } @@ -2495,7 +2495,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } @objc private func appWillResignActive() { - userDefaults.synchronize() + // No-op; avoid forcing synchronize on resign } @objc func applicationWillTerminate() { @@ -2508,15 +2508,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Verify identity key is still there _ = KeychainManager.shared.verifyIdentityKeyExists() - userDefaults.synchronize() + // No need to force synchronize here // Verify identity key after save _ = KeychainManager.shared.verifyIdentityKeyExists() } @objc private func appWillTerminate() { - - userDefaults.synchronize() + // No need to force synchronize here } @MainActor @@ -2682,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 } @@ -2761,14 +2760,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() - // Force immediate UserDefaults synchronization - userDefaults.synchronize() + // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity // 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() @@ -2938,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 @@ -3489,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) @@ -3699,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 } @@ -3997,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) @@ -4411,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 @@ -4482,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: @@ -4805,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 @@ -5033,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 { @@ -5257,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) } } @@ -5493,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 e26b0dc5..d41b3543 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -153,13 +153,13 @@ final class ShareViewController: UIViewController { userDefaults.set(content, forKey: "sharedContent") userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(Date(), forKey: "sharedContentDate") - userDefaults.synchronize() + // No need to force synchronize; the system persists changes } 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) } }