Compare commits

..
Author SHA1 Message Date
jack 6550a64676 project: add TransportConfig.swift to Share Extension target to fix build 2025-08-25 21:17:57 +02:00
jack 7e9d573f6d 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.
2025-08-25 21:13:17 +02:00
jack ed3583f2ff chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays 2025-08-25 20:57:52 +02:00
jack 2251afa360 chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules 2025-08-25 20:51:12 +02:00
jack c39df29b25 chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel 2025-08-25 20:17:09 +02:00
jack 43e159df0e chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport 2025-08-25 20:04:37 +02:00
jack adab49154b project: remove duplicate TransportConfig.swift entries from Sources build phases 2025-08-25 19:59:08 +02:00
jack 62d259cd9c chore(BLE): trim raw hex dump on central decode failure to length+prefix 2025-08-25 19:56:27 +02:00
jack 131db9c569 chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication 2025-08-25 19:50:45 +02:00
jack f5a2a1e29a chore(BLE): move connect/duty/announce constants to TransportConfig and reference them 2025-08-25 18:59:47 +02:00
jack 3a8cf4d37f project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources 2025-08-25 18:58:19 +02:00
jack b243b930c4 chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM) 2025-08-25 18:54:42 +02:00
jack c803f862d5 project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen) 2025-08-25 18:52:54 +02:00
jack 58c516075c 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) 2025-08-25 18:48:02 +02:00
680a390b2d Nostr: sign events directly with Schnorr keys; update call sites to use Schnorr and remove temporary Signing.PrivateKey conversions (#521)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 18:31:53 +02:00
17 changed files with 442 additions and 265 deletions
+26 -14
View File
@@ -17,7 +17,6 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; }; 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; }; 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
047502902E5417660083520F /* 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 */; }; 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
047502932E547ACC0083520F /* 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 */; }; 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 */; }; 047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
047502BA2E560F690083520F /* 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 */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.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 */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.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 */; }; 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, ); }; }; 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 */; }; 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 */; }; 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.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 */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA11BB22CC33DD44EE55FF67 /* 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 */; }; 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 */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.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 */; }; F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -200,6 +204,7 @@
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; }; 047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -215,6 +220,7 @@
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
@@ -245,9 +251,11 @@
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; }; 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; }; AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -266,9 +274,6 @@
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -323,6 +328,7 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -405,6 +411,7 @@
9A78348821A7D3374607D4E3 /* Utils */ = { 9A78348821A7D3374607D4E3 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */, 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */, 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
32F149C43D1915831B60FE09 /* CompressionUtil.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
@@ -443,7 +450,6 @@
047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
A08E03AA0C63E97C91749AEC /* ContentView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */,
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
@@ -514,6 +520,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
@@ -727,6 +734,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */, 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -735,11 +743,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
@@ -758,7 +769,6 @@
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
@@ -793,11 +803,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
@@ -816,7 +829,6 @@
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
+3 -3
View File
@@ -99,15 +99,15 @@ struct BitchatApp: App {
return return
} }
// Only process if shared within last 30 seconds // Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < 30 { if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content // Clear the shared content
userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate") userDefaults.removeObject(forKey: "sharedContentDate")
userDefaults.synchronize() // No need to force synchronize here
// Send the shared content immediately on the main queue // Send the shared content immediately on the main queue
DispatchQueue.main.async { DispatchQueue.main.async {
+1 -1
View File
@@ -14,7 +14,7 @@ final class GeoRelayDirectory {
private let cacheFileName = "georelays_cache.csv" private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! 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() { private init() {
// Load cached or bundled data synchronously // Load cached or bundled data synchronously
+9 -9
View File
@@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
private let backoffMultiplier: Double = 2.0 // Double each time private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = 10 // Stop after 10 attempts private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
@@ -289,7 +289,7 @@ class NostrRelayManager: ObservableObject {
// Only log non-gift-wrap events to reduce noise // Only log non-gift-wrap events to reduce noise
if event.kind != 1059 { 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) 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" let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = 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) category: SecureLogger.session, level: .debug)
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil 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) category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
} }
} }
@@ -353,7 +353,7 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" 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) category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
@@ -568,7 +568,7 @@ struct NostrFilter: Encodable {
filter.kinds = [1059] // Gift wrap kind filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]] filter.tagFilters = ["p": [pubkey]]
filter.limit = 100 // Add a reasonable limit filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
return filter return filter
} }
+77 -84
View File
@@ -22,12 +22,12 @@ final class BLEService: NSObject {
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
// Default per-fragment chunk size when link limits are unknown // Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let maxMessageLength = 10_000 private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = 7 private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls // Flood/battery controls
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections) // MARK: - Core State (5 Essential Collections)
@@ -70,7 +70,7 @@ final class BLEService: NSObject {
// Simple announce throttling // Simple announce throttling
private var lastAnnounceSent = Date.distantPast private var lastAnnounceSent = Date.distantPast
private let announceMinInterval: TimeInterval = 1.0 private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval
// Application state tracking (thread-safe) // Application state tracking (thread-safe)
#if os(iOS) #if os(iOS)
@@ -129,8 +129,8 @@ final class BLEService: NSObject {
private var maintenanceCounter = 0 // Track maintenance cycles private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Connection budget & scheduling (central role) // MARK: - Connection budget & scheduling (central role)
private let maxCentralLinks = 6 private let maxCentralLinks = TransportConfig.bleMaxCentralLinks
private let connectRateLimitInterval: TimeInterval = 0.5 private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval
private var lastGlobalConnectAttempt: Date = .distantPast private var lastGlobalConnectAttempt: Date = .distantPast
private struct ConnectionCandidate { private struct ConnectionCandidate {
let peripheral: CBPeripheral let peripheral: CBPeripheral
@@ -142,13 +142,13 @@ final class BLEService: NSObject {
private var connectionCandidates: [ConnectionCandidate] = [] private var connectionCandidates: [ConnectionCandidate] = []
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
private var lastIsolatedAt: Date? = nil private var lastIsolatedAt: Date? = nil
private var dynamicRSSIThreshold: Int = -90 private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
// MARK: - Adaptive scanning duty-cycle // MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer? private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = 5 private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
private var dutyOffDuration: TimeInterval = 10 private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
private var dutyActive: Bool = false private var dutyActive: Bool = false
// MARK: - Link capability snapshots (thread-safe via bleQueue) // MARK: - Link capability snapshots (thread-safe via bleQueue)
@@ -257,20 +257,15 @@ final class BLEService: NSObject {
func currentPeerSnapshots() -> [TransportPeerSnapshot] { func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync { collectionsQueue.sync {
// Compute nickname collision counts for connected peers let snapshot = Array(peers.values)
let connected = peers.values.filter { $0.isConnected } let resolvedNames = PeerDisplayNameResolver.resolve(
var counts: [String: Int] = [:] snapshot.map { ($0.id, $0.nickname, $0.isConnected) },
for p in connected { counts[p.nickname, default: 0] += 1 } selfNickname: myNickname
// Include our own nickname in collision counts so remote matching ours gets suffixed )
counts[myNickname, default: 0] += 1 return snapshot.map { info in
return peers.values.map { info in TransportPeerSnapshot(
var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4))
}
return TransportPeerSnapshot(
id: info.id, id: info.id,
nickname: display, nickname: resolvedNames[info.id] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen
@@ -351,7 +346,9 @@ final class BLEService: NSObject {
// Single maintenance timer for all periodic tasks (dispatch-based for determinism) // Single maintenance timer for all periodic tasks (dispatch-based for determinism)
let timer = DispatchSource.makeTimerSource(queue: bleQueue) 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 timer.setEventHandler { [weak self] in
self?.performMaintenance() self?.performMaintenance()
} }
@@ -433,7 +430,7 @@ final class BLEService: NSObject {
// Send initial announce after services are ready // Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces // 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) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -472,7 +469,7 @@ final class BLEService: NSObject {
} }
// Give leave message a moment to send // Give leave message a moment to send
Thread.sleep(forTimeInterval: 0.05) Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
// Clear pending notifications // Clear pending notifications
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -514,22 +511,9 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [String: String] { func getPeerNicknames() -> [String: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
// Only connected peers
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
// Count collisions by nickname (include our own nickname) let tuples = connected.map { ($0.key, $0.value.nickname, true) }
var counts: [String: Int] = [:] return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
for (_, info) in connected { counts[info.nickname, default: 0] += 1 }
counts[myNickname, default: 0] += 1
// Build map with suffix for collisions
var result: [String: String] = [:]
for (id, info) in connected {
var name = info.nickname
if (counts[info.nickname] ?? 0) > 1 {
name += "#" + String(id.prefix(4))
}
result[id] = name
}
return result
} }
} }
@@ -981,7 +965,7 @@ final class BLEService: NSObject {
if success { sentEncrypted = true; break } if success { sentEncrypted = true; break }
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.pendingNotifications.count < 20 { if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
self.pendingNotifications.append((data: data, centrals: [central])) self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
} }
@@ -1112,7 +1096,7 @@ final class BLEService: NSObject {
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() } if c.isScanning { c.stopScan() }
// Resume scanning after we expect last fragment to be sent // Resume scanning after we expect last fragment to be sent
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning() self?.startScanning()
} }
@@ -1143,7 +1127,7 @@ final class BLEService: NSObject {
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts // Pace fragments with small jitter to avoid bursts
let delayMs = index * 6 // ~6ms spacing per fragment let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket) self?.broadcastPacket(fragmentPacket)
} }
@@ -1255,10 +1239,10 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
let now = Date() let now = Date()
self.recentPacketTimestamps.append(now) self.recentPacketTimestamps.append(now)
// keep last 100 timestamps within 30s window // keep last N timestamps within window
let cutoff = now.addingTimeInterval(-30) let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if self.recentPacketTimestamps.count > 100 { if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100) self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
} }
self.recentPacketTimestamps.removeAll { $0 < cutoff } self.recentPacketTimestamps.removeAll { $0 < cutoff }
} }
@@ -1622,7 +1606,7 @@ final class BLEService: NSObject {
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
// Even forced sends should respect a minimum interval to avoid overwhelming BLE // Even forced sends should respect a minimum interval to avoid overwhelming BLE
let minInterval = forceSend ? 0.2 : announceMinInterval let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
if timeSinceLastAnnounce < minInterval { if timeSinceLastAnnounce < minInterval {
// Skipping announce (rate limited) // Skipping announce (rate limited)
@@ -1753,12 +1737,14 @@ final class BLEService: NSObject {
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let elapsed = now.timeIntervalSince(lastAnnounceSent) let elapsed = now.timeIntervalSince(lastAnnounceSent)
if connectedCount == 0 { if connectedCount == 0 {
// Discovery mode: keep frequent announces (~10s) // Discovery mode: keep frequent announces
if elapsed >= 10.0 { sendAnnounce(forceSend: true) } if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) }
} else { } else {
// Connected mode: announce less often; much less in dense networks // Connected mode: announce less often; much less in dense networks
let base = connectedCount >= 6 ? 90.0 : 45.0 let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ?
let jitter = connectedCount >= 6 ? 20.0 : 7.5 TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse
let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ?
TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse
let target = base + Double.random(in: -jitter...jitter) let target = base + Double.random(in: -jitter...jitter)
if elapsed >= target { sendAnnounce(forceSend: true) } if elapsed >= target { sendAnnounce(forceSend: true) }
} }
@@ -1799,7 +1785,7 @@ final class BLEService: NSObject {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { for (peerID, peer) in peers {
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 { if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
@@ -1839,9 +1825,9 @@ final class BLEService: NSObject {
// Clean old processed messages efficiently // Clean old processed messages efficiently
messageDeduplicator.cleanup() messageDeduplicator.cleanup()
// Clean old fragments (> 30 seconds old) // Clean old fragments (> configured seconds old)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
let cutoff = now.addingTimeInterval(-30) let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
for fragmentID in oldFragments { for fragmentID in oldFragments {
incomingFragments.removeValue(forKey: fragmentID) incomingFragments.removeValue(forKey: fragmentID)
@@ -1849,8 +1835,8 @@ final class BLEService: NSObject {
} }
} }
// Clean old connection timeout backoff entries (> 2 minutes) // Clean old connection timeout backoff entries (> window)
let timeoutCutoff = now.addingTimeInterval(-120) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
// Clean up stale scheduled relays that somehow persisted (> 2s) // Clean up stale scheduled relays that somehow persisted (> 2s)
@@ -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 collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let cutoff = now.addingTimeInterval(-3) let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
if !self.ingressByMessageID.isEmpty { if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
} }
@@ -1891,12 +1877,12 @@ final class BLEService: NSObject {
if !central.isScanning { startScanning() } if !central.isScanning { startScanning() }
dutyActive = true dutyActive = true
// Adjust duty cycle under dense networks to save battery // Adjust duty cycle under dense networks to save battery
if connectedCount >= 6 { if connectedCount >= TransportConfig.bleHighDegreeThreshold {
dutyOnDuration = 3 dutyOnDuration = TransportConfig.bleDutyOnDurationDense
dutyOffDuration = 15 dutyOffDuration = TransportConfig.bleDutyOffDurationDense
} else { } else {
dutyOnDuration = 5 dutyOnDuration = TransportConfig.bleDutyOnDuration
dutyOffDuration = 10 dutyOffDuration = TransportConfig.bleDutyOffDuration
} }
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in t.setEventHandler { [weak self] in
@@ -1930,25 +1916,25 @@ final class BLEService: NSObject {
if lastIsolatedAt == nil { lastIsolatedAt = Date() } if lastIsolatedAt == nil { lastIsolatedAt = Date() }
let iso = lastIsolatedAt ?? Date() let iso = lastIsolatedAt ?? Date()
let elapsed = Date().timeIntervalSince(iso) let elapsed = Date().timeIntervalSince(iso)
if elapsed > 60 { if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds {
dynamicRSSIThreshold = -92 dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed
} else { } else {
dynamicRSSIThreshold = -90 dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase
} }
return return
} }
lastIsolatedAt = nil lastIsolatedAt = nil
// Base threshold when connected // Base threshold when connected
var threshold = -90 var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
// If we're at budget or queue is large, prefer closer peers // If we're at budget or queue is large, prefer closer peers
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if linkCount >= maxCentralLinks || connectionCandidates.count > 20 { if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
threshold = -85 threshold = TransportConfig.bleRSSIConnectedThreshold
} }
// If we have many recent timeouts, raise further // If we have many recent timeouts, raise further
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count
if recentTimeouts >= 3 { if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold {
threshold = max(threshold, -80) threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
} }
dynamicRSSIThreshold = threshold dynamicRSSIThreshold = threshold
} }
@@ -2002,7 +1988,9 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -2016,7 +2004,9 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -2093,7 +2083,7 @@ extension BLEService: CBCentralManagerDelegate {
// Set a timeout for the connection attempt (slightly longer for reliability) // Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently // 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, guard let self = self,
let state = self.peripherals[peripheralID], let state = self.peripherals[peripheralID],
state.isConnecting && !state.isConnected else { return } state.isConnecting && !state.isConnected else { return }
@@ -2165,7 +2155,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if centralManager?.state == .poweredOn { if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events // Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan() centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
self?.startScanning() self?.startScanning()
} }
} }
@@ -2363,7 +2353,7 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) 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) // 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) self?.sendAnnounce(forceSend: true)
} }
} else { } else {
@@ -2386,7 +2376,9 @@ extension BLEService: CBPeripheralDelegate {
// Process directly on main thread to avoid deadlocks (matches original implementation) // Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else { 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) category: SecureLogger.session, level: .error)
return return
} }
@@ -2523,7 +2515,7 @@ extension BLEService: CBPeripheralManagerDelegate {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug)
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // 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) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -2682,13 +2674,14 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
} else { } else {
// If buffer grows suspiciously large, reset to avoid memory leak // 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) pendingWriteBuffers.removeValue(forKey: centralUUID)
SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) 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 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 { 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)
} }
} }
} }
@@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
super.init() super.init()
cl.delegate = self cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = 1000 // meters; we're not tracking continuously cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
// Load selection // Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey), if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { 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. /// 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 } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Switch to a lightweight periodic one-shot request (polling) while the sheet is open
refreshTimer?.invalidate() refreshTimer?.invalidate()
+21 -38
View File
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
} }
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = 0.35 // ~3 per second private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
var myPeerID: String { senderPeerID } var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
@@ -48,16 +48,7 @@ final class NostrTransport: Transport {
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
// Resolve favorite by full noise key or by short peerID fallback guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
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 senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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))", SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -105,15 +96,7 @@ final class NostrTransport: Transport {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
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 senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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))", SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -149,15 +132,7 @@ final class NostrTransport: Transport {
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
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 senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", 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 sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
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 senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() 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))", SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = 1337 private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: String) {
+1 -1
View File
@@ -3,7 +3,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let id: String
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
+161
View File
@@ -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
}
+1 -1
View File
@@ -102,7 +102,7 @@ final class VerificationService {
} }
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) /// 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 } guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness // Freshness
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
+1 -1
View File
@@ -11,7 +11,7 @@ import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // 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) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
+2 -3
View File
@@ -11,8 +11,8 @@ final class MessageDeduplicator {
private var entries: [Entry] = [] private var entries: [Entry] = []
private var lookup = Set<String>() private var lookup = Set<String>()
private let lock = NSLock() private let lock = NSLock()
private let maxAge: TimeInterval = 300 // 5 minutes private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
private let maxCount = 1000 private let maxCount = TransportConfig.messageDedupMaxCount
/// Check if message is duplicate and add if not /// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool { func isDuplicate(_ messageID: String) -> Bool {
@@ -84,4 +84,3 @@ final class MessageDeduplicator {
} }
} }
} }
@@ -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
}
}
+80 -80
View File
@@ -145,10 +145,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var rateBucketsBySender: [String: TokenBucket] = [:] private var rateBucketsBySender: [String: TokenBucket] = [:]
private var rateBucketsByContent: [String: TokenBucket] = [:] private var rateBucketsByContent: [String: TokenBucket] = [:]
private let senderBucketCapacity: Double = 5 private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
private let senderBucketRefill: Double = 1 // tokens per second private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second
private let contentBucketCapacity: Double = 3 private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity
private let contentBucketRefill: Double = 0.5 // tokens per second private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second
@MainActor @MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String { private func normalizedSenderKey(for message: BitchatMessage) -> String {
@@ -192,7 +192,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) } if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) }
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
let prefix = String(collapsed.prefix(256)) let prefix = String(collapsed.prefix(TransportConfig.contentKeyPrefixLength))
// Fast djb2 hash // Fast djb2 hash
let h = djb2(prefix) let h = djb2(prefix)
return String(format: "h:%016llx", h) return String(format: "h:%016llx", h)
@@ -201,7 +201,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Persistent recent content map (LRU) to speed near-duplicate checks // Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:] private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = [] private var contentLRUOrder: [String] = []
private let contentLRUCap = 2000 private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) { private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) } if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp contentLRUMap[key] = timestamp
@@ -219,13 +219,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var messages: [BitchatMessage] = [] @Published var messages: [BitchatMessage] = []
@Published var currentColorScheme: ColorScheme = .light @Published var currentColorScheme: ColorScheme = .light
private let maxMessages = 1337 // Maximum messages before oldest are removed private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false @Published var isConnected = false
private var hasNotifiedNetworkAvailable = false private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = [] private var recentlySeenPeers: Set<String> = []
private var lastNetworkNotificationTime = Date.distantPast private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? = nil private var networkResetTimer: Timer? = nil
private let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes; avoid refiring on short drops/reconnects private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" { @Published var nickname: String = "" {
didSet { didSet {
// Trim whitespace whenever nickname is set // Trim whitespace whenever nickname is set
@@ -349,7 +349,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// PeerManager replaced by UnifiedPeerService // PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = [] private var processedNostrEventOrder: [String] = []
private let maxProcessedNostrEvents = 2000 private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection) // Location channel state (macOS supports manual geohash selection)
@@ -386,14 +386,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Messages are naturally ephemeral - no persistent storage // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = [] private var meshTimeline: [BitchatMessage] = []
private let meshTimelineCap = 1337 private let meshTimelineCap = TransportConfig.meshTimelineCap
// Persist per-geohash public timelines across switches // Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = 1337 private let geoTimelineCap = TransportConfig.geoTimelineCap
// Channel activity tracking for background nudges // Channel activity tracking for background nudges
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:] private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = 9 * 60 private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participants (per geohash: pubkey -> lastSeen) // Geohash participants (per geohash: pubkey -> lastSeen)
private var geoParticipants: [String: [String: Date]] = [:] private var geoParticipants: [String: [String: Date]] = [:]
@Published private(set) var geohashPeople: [GeoPerson] = [] @Published private(set) var geohashPeople: [GeoPerson] = []
@@ -428,11 +428,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Buffer incoming public messages and flush in small batches to reduce UI invalidations // Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = [] private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil private var publicBufferTimer: Timer? = nil
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching private let basePublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var dynamicPublicFlushInterval: TimeInterval = 0.08 private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var recentBatchSizes: [Int] = [] private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false @Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = 15.0 private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
// Track sent read receipts to avoid duplicates (persisted across launches) // Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed // Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -441,21 +441,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Only persist if there are changes // Only persist if there are changes
guard oldValue != sentReadReceipts else { return } guard oldValue != sentReadReceipts else { return }
// Persist to UserDefaults whenever it changes // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts") UserDefaults.standard.set(data, forKey: "sentReadReceipts")
// Force synchronization for immediate persistence (ensures data is written to disk)
UserDefaults.standard.synchronize()
// Verify persistence by re-reading
if let verifyData = UserDefaults.standard.data(forKey: "sentReadReceipts"),
let _ = try? JSONDecoder().decode([String].self, from: verifyData) {
// Only log errors, not successful persistence
// Successfully persisted
} else {
SecureLogger.log("⚠️ Failed to verify persistence of read receipts",
category: SecureLogger.session, level: .error)
}
} else { } else {
SecureLogger.log("❌ Failed to encode read receipts for persistence", SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error) category: SecureLogger.session, level: .error)
@@ -518,7 +506,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Log startup info // Log startup info
// Log fingerprint after a delay to ensure encryption service is ready // Log fingerprint after a delay to ensure encryption service is ready
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
if let self = self { if let self = self {
_ = self.getMyFingerprint() _ = self.getMyFingerprint()
} }
@@ -538,7 +526,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Small delay to ensure read receipts are fully loaded // Small delay to ensure read receipts are fully loaded
// This prevents race conditions where messages arrive before initialization completes // This prevents race conditions where messages arrive before initialization completes
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs) // 0.2 seconds
// Set up Nostr message handling directly // Set up Nostr message handling directly
setupNostrMessageHandling() setupNostrMessageHandling()
@@ -551,7 +539,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// 1. Skip cleanup of read receipts // 1. Skip cleanup of read receipts
// 2. Only block OLD messages from being marked as unread // 2. Only block OLD messages from being marked as unread
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) // 2 seconds
self.isStartupPhase = false self.isStartupPhase = false
} }
@@ -703,8 +691,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Deinitialization // MARK: - Deinitialization
deinit { deinit {
// Force immediate save // No need to force UserDefaults synchronization
userDefaults.synchronize()
} }
// Resubscribe to the active geohash channel without clearing timeline // Resubscribe to the active geohash channel without clearing timeline
@@ -720,8 +707,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
startGeoParticipantsTimer() startGeoParticipantsTimer()
// Unsubscribe + resubscribe // Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID) NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) let filter = NostrFilter.geohashEphemeral(
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) 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 NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue 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 self.geoNicknames[event.pubkey.lowercased()] = nick
} }
// Store mapping for geohash sender IDs used in messages (ensures consistent colors) // Store mapping for geohash sender IDs used in messages (ensures consistent colors)
let key16 = "nostr_" + String(event.pubkey.prefix(16)) let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
self.nostrKeyMapping[key16] = event.pubkey self.nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(8)) let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey // Update participants last-seen for this pubkey
@@ -768,7 +762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "nostr:\(event.pubkey.prefix(8))", senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
Task { @MainActor in Task { @MainActor in
@@ -783,7 +777,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let dmSub = "geo-dm-\(ch.geohash)" let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return } guard let self = self else { return }
if self.processedNostrEvents.contains(giftWrap.id) { return } if self.processedNostrEvents.contains(giftWrap.id) { return }
@@ -795,7 +789,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard packet.type == MessageType.noiseEncrypted.rawValue else { return } guard packet.type == MessageType.noiseEncrypted.rawValue else { return }
guard let noisePayload = NoisePayload.decode(packet.payload) else { return } guard let noisePayload = NoisePayload.decode(packet.payload) else { return }
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = "nostr_" + String(senderPubkey.prefix(16)) let convKey = "nostr_" + String(senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
self.nostrKeyMapping[convKey] = senderPubkey self.nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type { switch noisePayload.type {
case .privateMessage: case .privateMessage:
@@ -903,7 +897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func saveNickname() { func saveNickname() {
userDefaults.set(nickname, forKey: nicknameKey) 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 // Send announce with new nickname to all peers
meshService.sendBroadcastAnnounce() meshService.sendBroadcastAnnounce()
@@ -1095,7 +1089,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
chatFingerprint == fingerprintStr { chatFingerprint == fingerprintStr {
// Send read receipts for any unread messages from this peer // Send read receipts for any unread messages from this peer
// Use a small delay to ensure the connection is fully established // Use a small delay to ensure the connection is fully established
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID) self?.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -1221,7 +1215,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4)) let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix displaySender = nickname + "#" + suffix
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))" localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
} }
let message = BitchatMessage( let message = BitchatMessage(
@@ -1277,7 +1271,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nickname: self.nickname, nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported 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 { if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
} else { } else {
@@ -1359,7 +1356,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let subID = "geo-\(ch.geohash)" let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID geoSubscriptionID = subID
startGeoParticipantsTimer() startGeoParticipantsTimer()
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
@@ -1401,9 +1402,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return return
} }
// Store mapping for geohash DM initiation // Store mapping for geohash DM initiation
let key16 = "nostr_" + String(event.pubkey.prefix(16)) let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
self.nostrKeyMapping[key16] = event.pubkey self.nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(8)) let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey // Update participants last-seen for this pubkey
self.recordGeoParticipant(pubkeyHex: event.pubkey) self.recordGeoParticipant(pubkeyHex: event.pubkey)
@@ -1426,7 +1427,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "nostr:\(event.pubkey.prefix(8))", senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
Task { @MainActor in Task { @MainActor in
@@ -1444,7 +1445,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// pared back logging: subscribe debug only // pared back logging: subscribe debug only
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return } guard let self = self else { return }
// Dedup basic // Dedup basic
@@ -1593,7 +1594,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func refreshGeohashPeople() { private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return } guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-5 * 60) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
var map = geoParticipants[gh] ?? [:] var map = geoParticipants[gh] ?? [:]
// Prune expired entries // Prune expired entries
map = map.filter { $0.value >= cutoff } map = map.filter { $0.value >= cutoff }
@@ -1628,7 +1629,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func visibleGeohashPeople() -> [GeoPerson] { func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] } guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-5 * 60) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = (geoParticipants[gh] ?? [:]) let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff } .filter { $0.value >= cutoff }
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } .filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
@@ -1640,7 +1641,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Returns the current participant count for a specific geohash, using the 5-minute activity window. /// Returns the current participant count for a specific geohash, using the 5-minute activity window.
@MainActor @MainActor
func geohashParticipantCount(for geohash: String) -> Int { func geohashParticipantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(-5 * 60) let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = geoParticipants[geohash] ?? [:] let map = geoParticipants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count return map.values.filter { $0 >= cutoff }.count
} }
@@ -1688,7 +1689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Remove geohash DM conversation if exists // Remove geohash DM conversation if exists
let convKey = "nostr_" + String(hex.prefix(16)) let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
if privateChats[convKey] != nil { if privateChats[convKey] != nil {
privateChats.removeValue(forKey: convKey) privateChats.removeValue(forKey: convKey)
unreadPrivateMessages.remove(convKey) unreadPrivateMessages.remove(convKey)
@@ -1724,7 +1725,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for gh in toAdd { for gh in toAdd {
let subID = "geo-sample-\(gh)" let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100) let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
@@ -1927,7 +1932,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Geohash DMs initiation // MARK: - Geohash DMs initiation
@MainActor @MainActor
func startGeohashDM(withPubkeyHex hex: String) { func startGeohashDM(withPubkeyHex hex: String) {
let convKey = "nostr_" + String(hex.prefix(16)) let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = hex nostrKeyMapping[convKey] = hex
selectedPrivateChatPeer = convKey selectedPrivateChatPeer = convKey
} }
@@ -2397,7 +2402,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try immediately // Try immediately
self.markPrivateMessagesAsRead(from: peerID) self.markPrivateMessagesAsRead(from: peerID)
// And again with a delay // And again with a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) {
self.markPrivateMessagesAsRead(from: peerID) self.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -2463,12 +2468,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
) )
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty { if targetRelays.isEmpty {
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
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)
}
} else { } else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
@@ -2495,7 +2495,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
@objc private func appWillResignActive() { @objc private func appWillResignActive() {
userDefaults.synchronize() // No-op; avoid forcing synchronize on resign
} }
@objc func applicationWillTerminate() { @objc func applicationWillTerminate() {
@@ -2508,15 +2508,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Verify identity key is still there // Verify identity key is still there
_ = KeychainManager.shared.verifyIdentityKeyExists() _ = KeychainManager.shared.verifyIdentityKeyExists()
userDefaults.synchronize() // No need to force synchronize here
// Verify identity key after save // Verify identity key after save
_ = KeychainManager.shared.verifyIdentityKeyExists() _ = KeychainManager.shared.verifyIdentityKeyExists()
} }
@objc private func appWillTerminate() { @objc private func appWillTerminate() {
// No need to force synchronize here
userDefaults.synchronize()
} }
@MainActor @MainActor
@@ -2682,7 +2681,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}().lowercased() }().lowercased()
// Try exact match against cached geoNicknames (pubkey -> nickname) // Try exact match against cached geoNicknames (pubkey -> nickname)
if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key {
let convKey = "nostr_" + String(pub.prefix(16)) let convKey = "nostr_" + String(pub.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = pub nostrKeyMapping[convKey] = pub
return convKey return convKey
} }
@@ -2761,14 +2760,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// This will force creation of a new identity (new fingerprint) on next launch // This will force creation of a new identity (new fingerprint) on next launch
meshService.emergencyDisconnectAll() meshService.emergencyDisconnectAll()
// Force immediate UserDefaults synchronization // No need to force UserDefaults synchronization
userDefaults.synchronize()
// Reinitialize Nostr with new identity // Reinitialize Nostr with new identity
// This will generate new Nostr keys derived from new Noise keys // This will generate new Nostr keys derived from new Noise keys
Task { @MainActor in Task { @MainActor in
// Small delay to ensure cleanup completes // Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity // Reinitialize Nostr relay manager with new identity
nostrRelayManager = NostrRelayManager() nostrRelayManager = NostrRelayManager()
@@ -2938,7 +2936,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// In geohash channels, compare against our per-geohash nostr short ID // In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))" return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
} }
} }
return spid == meshService.myPeerID return spid == meshService.myPeerID
@@ -3489,7 +3487,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var hue = Double(djb2(seed) % 360) / 360.0 var hue = Double(djb2(seed) % 360) / 360.0
// Avoid orange (~30°) reserved for self // Avoid orange (~30°) reserved for self
let orange = 30.0 / 360.0 let orange = 30.0 / 360.0
if abs(hue - orange) < 0.05 { hue = fmod(hue + 0.12, 1.0) } if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
}
let saturation: Double = isDark ? 0.80 : 0.70 let saturation: Double = isDark ? 0.80 : 0.70
let brightness: Double = isDark ? 0.75 : 0.45 let brightness: Double = isDark ? 0.75 : 0.45
let c = Color(hue: hue, saturation: saturation, brightness: brightness) let c = Color(hue: hue, saturation: saturation, brightness: brightness)
@@ -3699,7 +3699,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Load verified fingerprints directly from secure storage // Load verified fingerprints directly from secure storage
verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints()
// Log snapshot for debugging persistence // Log snapshot for debugging persistence
let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ") let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ")
SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info) SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info)
// Also log any offline favorites and whether we consider them verified // Also log any offline favorites and whether we consider them verified
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
@@ -3997,7 +3997,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey),
favoriteStatus.isFavorite { favoriteStatus.isFavorite {
// Resend favorite notification with our Nostr key after a short delay // Resend favorite notification with our Nostr key after a short delay
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds
meshService.sendFavoriteNotification(to: peerID, isFavorite: true) meshService.sendFavoriteNotification(to: peerID, isFavorite: true)
SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)", SecureLogger.log("📤 Resent favorite notification to reconnected peer \(peerID)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -4411,7 +4411,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Subscribe to Nostr messages // Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor( let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex, pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-86400) // Last 24 hours since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
) )
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
@@ -4482,7 +4482,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown" let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown"
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(16)) let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
switch noisePayload.type { switch noisePayload.type {
case .privateMessage: case .privateMessage:
@@ -4805,7 +4805,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For now, create a temporary peer ID based on Nostr pubkey // For now, create a temporary peer ID based on Nostr pubkey
// This allows the message to be displayed even without Noise key mapping // This allows the message to be displayed even without Noise key mapping
let tempPeerID = "nostr_" + senderPubkey.prefix(16) let tempPeerID = "nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)
// Check if we're viewing this unknown sender's chat // Check if we're viewing this unknown sender's chat
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
@@ -5033,7 +5033,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var oldPeerIDsToRemove: [String] = [] var oldPeerIDsToRemove: [String] = []
// Only migrate messages from the last 24 hours to prevent old messages from flooding // Only migrate messages from the last 24 hours to prevent old messages from flooding
let cutoffTime = Date().addingTimeInterval(-24 * 60 * 60) let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in privateChats { for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID { if oldPeerID != peerID {
@@ -5257,7 +5257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Mark other messages as read // Mark other messages as read
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID) self?.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -5493,7 +5493,7 @@ private func checkForMentions(_ message: BitchatMessage) {
impactFeedback.prepare() impactFeedback.prepare()
for i in 0..<8 { for i in 0..<8 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) {
impactFeedback.impactOccurred() impactFeedback.impactOccurred()
} }
} }
+25 -25
View File
@@ -100,14 +100,14 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
if translation > 50 || (translation > 30 && velocity > 300) { if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false showPrivateChat = false
backSwipeOffset = 0 backSwipeOffset = 0
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
} else { } else {
withAnimation(.easeOut(duration: 0.15)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
backSwipeOffset = 0 backSwipeOffset = 0
} }
} }
@@ -121,7 +121,7 @@ struct ContentView: View {
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -151,14 +151,14 @@ struct ContentView: View {
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width) let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
return showSidebar ? -dragOffset : width - dragOffset return showSidebar ? -dragOffset : width - dragOffset
}()) }())
.animation(.easeInOut(duration: 0.25), value: showSidebar) .animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
} }
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
#endif #endif
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = newValue != nil showPrivateChat = newValue != nil
} }
} }
@@ -195,7 +195,7 @@ struct ContentView: View {
} else { } else {
viewModel.startPrivateChat(with: peerID) viewModel.startPrivateChat(with: peerID)
} }
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -264,7 +264,7 @@ struct ContentView: View {
// Implement windowing with adjustable window count per chat // Implement windowing with adjustable window count per chat
let currentWindowCount: Int = { let currentWindowCount: Int = {
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 } if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate }
return windowCountPublic return windowCountPublic
}() }()
let windowedMessages = messages.suffix(currentWindowCount) let windowedMessages = messages.suffix(currentWindowCount)
@@ -296,11 +296,11 @@ struct ContentView: View {
let cashuTokens = message.content.extractCashuTokens() let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages // Delivery status indicator for private messages
@@ -312,7 +312,7 @@ struct ContentView: View {
} }
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty { if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "show less" : "show more") { Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
@@ -371,7 +371,7 @@ struct ContentView: View {
} }
// Infinite scroll up: when top row appears, increase window and preserve anchor // Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = 200 let step = TransportConfig.uiWindowStepCount
let contextKey: String = { let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" } if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
@@ -381,7 +381,7 @@ struct ContentView: View {
}() }()
let preserveID = "\(contextKey)|\(message.id)" let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer { if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? 300 let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
let newCount = min(messages.count, current + step) let newCount = min(messages.count, current + step)
if newCount != current { if newCount != current {
windowCountPrivate[peer] = newCount windowCountPrivate[peer] = newCount
@@ -540,7 +540,7 @@ struct ContentView: View {
} }
// Throttle scroll animations to prevent excessive UI updates // Throttle scroll animations to prevent excessive UI updates
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
// Immediate scroll if enough time has passed // Immediate scroll if enough time has passed
lastScrollTime = now lastScrollTime = now
let contextKey: String = { let contextKey: String = {
@@ -557,7 +557,7 @@ struct ContentView: View {
} else { } else {
// Schedule a delayed scroll // Schedule a delayed scroll
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
@@ -589,7 +589,7 @@ struct ContentView: View {
} }
// Same throttling for private chats // Same throttling for private chats
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
lastScrollTime = now lastScrollTime = now
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -599,7 +599,7 @@ struct ContentView: View {
} }
} else { } else {
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -619,7 +619,7 @@ struct ContentView: View {
break break
case .location(let ch): case .location(let ch):
// Reset window size // Reset window size
windowCountPublic = 300 windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)" let contextKey = "geo:\(ch.geohash)"
let last = viewModel.messages.suffix(windowCountPublic).last?.id let last = viewModel.messages.suffix(windowCountPublic).last?.id
let target = last.map { "\(contextKey)|\($0)" } let target = last.map { "\(contextKey)|\($0)" }
@@ -635,11 +635,11 @@ struct ContentView: View {
// Try multiple times to ensure read receipts are sent // Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -859,7 +859,7 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
// Delay keyboard focus to avoid iOS constraint warnings // Delay keyboard focus to avoid iOS constraint warnings
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
isTextFieldFocused = true isTextFieldFocused = true
} }
} }
@@ -925,7 +925,7 @@ struct ContentView: View {
secondaryTextColor: secondaryTextColor, secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID) viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -973,7 +973,7 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
if !showSidebar { if !showSidebar {
if translation < -100 || (translation < -50 && velocity < -500) { if translation < -100 || (translation < -50 && velocity < -500) {
showSidebar = true showSidebar = true
@@ -1153,7 +1153,7 @@ struct ContentView: View {
// QR moved to the PEOPLE header in the sidebar when on mesh channel // QR moved to the PEOPLE header in the sidebar when on mesh channel
} }
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle() showSidebar.toggle()
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -1294,7 +1294,7 @@ struct ContentView: View {
// Left and right buttons positioned with HStack // Left and right buttons positioned with HStack
HStack { HStack {
Button(action: { Button(action: {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false showPrivateChat = false
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
@@ -153,13 +153,13 @@ final class ShareViewController: UIViewController {
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
userDefaults.synchronize() // No need to force synchronize; the system persists changes
} }
private func finishWithMessage(_ msg: String) { private func finishWithMessage(_ msg: String) {
statusLabel.text = msg statusLabel.text = msg
// Complete shortly after showing status // Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
} }
} }