mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc5cbe0b7 | ||
|
|
77f0fa46c7 | ||
|
|
81fa77b761 | ||
|
|
0c7054ce30 | ||
|
|
921b9f1be6 | ||
|
|
e86dbc8d38 | ||
|
|
652deab8a5 |
@@ -71,7 +71,3 @@ __pycache__/
|
|||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
TestResult.xcresult/
|
|
||||||
*.xcresult/
|
|
||||||
build.log
|
|
||||||
*.log
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@
|
|||||||
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 */; };
|
||||||
@@ -59,8 +56,6 @@
|
|||||||
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 */; };
|
||||||
@@ -107,17 +102,11 @@
|
|||||||
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 */; };
|
||||||
@@ -151,6 +140,12 @@
|
|||||||
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 */
|
||||||
@@ -204,7 +199,6 @@
|
|||||||
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>"; };
|
||||||
@@ -220,7 +214,6 @@
|
|||||||
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>"; };
|
||||||
@@ -251,11 +244,8 @@
|
|||||||
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>"; };
|
||||||
@@ -274,6 +264,9 @@
|
|||||||
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 */
|
||||||
@@ -328,7 +321,6 @@
|
|||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
048A4BE52E5CCC5C00162C4A /* Recovered References */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -411,7 +403,6 @@
|
|||||||
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 */,
|
||||||
@@ -520,7 +511,6 @@
|
|||||||
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 */,
|
||||||
@@ -734,7 +724,6 @@
|
|||||||
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;
|
||||||
@@ -743,14 +732,11 @@
|
|||||||
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 */,
|
||||||
@@ -803,14 +789,11 @@
|
|||||||
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 */,
|
||||||
|
|||||||
@@ -99,18 +99,30 @@ struct BitchatApp: App {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process if shared within configured window
|
// Only process if shared within last 30 seconds
|
||||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
if Date().timeIntervalSince(sharedDate) < 30 {
|
||||||
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")
|
||||||
// No need to force synchronize here
|
userDefaults.synchronize()
|
||||||
|
|
||||||
// Send the shared content immediately on the main queue
|
// Show notification about shared content
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
// Add system message about sharing
|
||||||
|
let systemMessage = BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: "preparing to share \(contentType)...",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
self.chatViewModel.messages.append(systemMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the shared content after a short delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
if contentType == "url" {
|
if contentType == "url" {
|
||||||
// Try to parse as JSON first
|
// Try to parse as JSON first
|
||||||
if let data = sharedContent.data(using: .utf8),
|
if let data = sharedContent.data(using: .utf8),
|
||||||
|
|||||||
@@ -173,7 +173,56 @@ struct PendingActions {
|
|||||||
var setPetname: String?
|
var setPetname: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// MARK: - Privacy Settings
|
||||||
|
|
||||||
|
struct PrivacySettings: Codable {
|
||||||
|
// Level 1: Maximum privacy (default)
|
||||||
|
var persistIdentityCache = false
|
||||||
|
var showLastSeen = false
|
||||||
|
|
||||||
|
// Level 2: Convenience
|
||||||
|
var autoAcceptKnownFingerprints = false
|
||||||
|
var rememberNicknameHistory = false
|
||||||
|
|
||||||
|
// Level 3: Social
|
||||||
|
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Resolution
|
||||||
|
|
||||||
|
/// Strategies for resolving identity conflicts in the decentralized network.
|
||||||
|
/// Handles cases where multiple peers claim the same nickname or when
|
||||||
|
/// identity mappings become ambiguous due to network partitions.
|
||||||
|
enum ConflictResolution {
|
||||||
|
case acceptNew(petname: String) // "John (2)"
|
||||||
|
case rejectNew
|
||||||
|
case blockFingerprint(String)
|
||||||
|
case alertUser(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI State
|
||||||
|
|
||||||
|
struct PeerUIState {
|
||||||
|
let peerID: String
|
||||||
|
let nickname: String
|
||||||
|
var identityState: IdentityState
|
||||||
|
var connectionQuality: ConnectionQuality
|
||||||
|
|
||||||
|
enum IdentityState {
|
||||||
|
case unknown // Gray - No identity info
|
||||||
|
case unverifiedKnown(String) // Blue - Handshake done, matches cache
|
||||||
|
case verified(String) // Green - Cryptographically verified
|
||||||
|
case conflict(String, String) // Red - Nickname doesn't match fingerprint
|
||||||
|
case pending // Yellow - Handshake in progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionQuality {
|
||||||
|
case excellent
|
||||||
|
case good
|
||||||
|
case poor
|
||||||
|
case disconnected
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Migration Support
|
// MARK: - Migration Support
|
||||||
//
|
// Removed LegacyFavorite - no longer needed
|
||||||
|
|||||||
@@ -89,4 +89,219 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// MARK: - Peer Manager
|
||||||
|
|
||||||
|
/// Manages the collection of peers and their states
|
||||||
|
@MainActor
|
||||||
|
class PeerManager: ObservableObject {
|
||||||
|
@Published var peers: [BitchatPeer] = []
|
||||||
|
@Published var favorites: [BitchatPeer] = []
|
||||||
|
@Published var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
|
private let meshService: Transport
|
||||||
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
|
|
||||||
|
init(meshService: Transport) {
|
||||||
|
self.meshService = meshService
|
||||||
|
updatePeers()
|
||||||
|
|
||||||
|
// Listen for updates
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleFavoriteChanged),
|
||||||
|
name: .favoriteStatusChanged,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleFavoriteChanged() {
|
||||||
|
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
updatePeers()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePeers() {
|
||||||
|
// Reduce log verbosity - only log when count changes
|
||||||
|
let previousCount = peers.count
|
||||||
|
|
||||||
|
// Get current mesh peers
|
||||||
|
let meshPeers = meshService.getPeerNicknames()
|
||||||
|
|
||||||
|
// Build peer list
|
||||||
|
var allPeers: [BitchatPeer] = []
|
||||||
|
var connectedNicknames: Set<String> = []
|
||||||
|
var addedPeerIDs: Set<String> = []
|
||||||
|
|
||||||
|
// Add connected mesh peers (only if actually connected or relay connected)
|
||||||
|
for (peerID, nickname) in meshPeers {
|
||||||
|
guard let noiseKey = Data(hexString: peerID) else { continue }
|
||||||
|
|
||||||
|
// Safety check: Never add our own peer ID
|
||||||
|
if peerID == meshService.myPeerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this peer is actually connected
|
||||||
|
let isConnected = meshService.isPeerConnected(peerID)
|
||||||
|
|
||||||
|
// Skip disconnected peers unless they're favorites (handled later)
|
||||||
|
if !isConnected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isConnected {
|
||||||
|
connectedNicknames.insert(nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track that we've added this peer ID
|
||||||
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
|
var peer = BitchatPeer(
|
||||||
|
id: peerID,
|
||||||
|
noisePublicKey: noiseKey,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: isConnected
|
||||||
|
)
|
||||||
|
// Set favorite status - check both by current noise key and by nickname
|
||||||
|
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
|
||||||
|
peer.favoriteStatus = favoriteStatus
|
||||||
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||||
|
} else {
|
||||||
|
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
|
||||||
|
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
|
||||||
|
if let favorite = favoriteByNickname {
|
||||||
|
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
// Update the favorite's noise key to match the current connection
|
||||||
|
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
|
||||||
|
// Get the updated favorite with the new key
|
||||||
|
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
||||||
|
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allPeers.append(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add offline favorites (only those not currently connected AND that we actively favorite)
|
||||||
|
|
||||||
|
for (favoriteKey, favorite) in favoritesService.favorites {
|
||||||
|
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
|
||||||
|
|
||||||
|
// Skip if this peer is already connected (by nickname)
|
||||||
|
if connectedNicknames.contains(favorite.peerNickname) {
|
||||||
|
// Skipping favorite - already connected
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we already added a peer with this ID (prevents duplicates)
|
||||||
|
if addedPeerIDs.contains(favoriteID) {
|
||||||
|
// Skipping favorite - peer ID already added
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add peers that WE favorite (not just ones who favorite us)
|
||||||
|
if !favorite.isFavorite {
|
||||||
|
// Skipping - we don't favorite them
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this favorite as an offline peer
|
||||||
|
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
|
||||||
|
var peer = BitchatPeer(
|
||||||
|
id: favoriteID,
|
||||||
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
|
nickname: favorite.peerNickname,
|
||||||
|
isConnected: false
|
||||||
|
)
|
||||||
|
// Set favorite status
|
||||||
|
peer.favoriteStatus = favorite
|
||||||
|
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
||||||
|
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
|
||||||
|
allPeers.append(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
|
||||||
|
allPeers = allPeers.filter { peer in
|
||||||
|
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: Connected first, then favorites, then alphabetical
|
||||||
|
allPeers.sort { lhs, rhs in
|
||||||
|
// Direct connections first
|
||||||
|
if lhs.isConnected != rhs.isConnected {
|
||||||
|
return lhs.isConnected
|
||||||
|
}
|
||||||
|
// Then favorites
|
||||||
|
if lhs.isFavorite != rhs.isFavorite {
|
||||||
|
return lhs.isFavorite
|
||||||
|
}
|
||||||
|
// Finally alphabetical
|
||||||
|
return lhs.displayName < rhs.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single pass to compute all subsets and counts
|
||||||
|
var favorites: [BitchatPeer] = []
|
||||||
|
var mutualFavorites: [BitchatPeer] = []
|
||||||
|
var connectedCount = 0
|
||||||
|
var offlineCount = 0
|
||||||
|
|
||||||
|
for peer in allPeers {
|
||||||
|
if peer.isFavorite {
|
||||||
|
favorites.append(peer)
|
||||||
|
}
|
||||||
|
if peer.isMutualFavorite {
|
||||||
|
mutualFavorites.append(peer)
|
||||||
|
}
|
||||||
|
if peer.isConnected {
|
||||||
|
connectedCount += 1
|
||||||
|
} else {
|
||||||
|
offlineCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety check: ensure no duplicate IDs
|
||||||
|
var finalPeers: [BitchatPeer] = []
|
||||||
|
var seenIDs: Set<String> = []
|
||||||
|
for peer in allPeers {
|
||||||
|
if !seenIDs.contains(peer.id) {
|
||||||
|
seenIDs.insert(peer.id)
|
||||||
|
finalPeers.append(peer)
|
||||||
|
} else {
|
||||||
|
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
|
||||||
|
category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.peers = finalPeers
|
||||||
|
self.favorites = favorites
|
||||||
|
self.mutualFavorites = mutualFavorites
|
||||||
|
|
||||||
|
// Log peer list summary sparingly at debug level
|
||||||
|
if favoritesService.favorites.count > 0 {
|
||||||
|
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
} else if previousCount != allPeers.count {
|
||||||
|
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(_ peer: BitchatPeer) {
|
||||||
|
if peer.isFavorite {
|
||||||
|
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||||
|
} else {
|
||||||
|
favoritesService.addFavorite(
|
||||||
|
peerNoisePublicKey: peer.noisePublicKey,
|
||||||
|
peerNostrPublicKey: peer.nostrPublicKey,
|
||||||
|
peerNickname: peer.nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updatePeers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
// Load cached or bundled data synchronously
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ struct NostrProtocol {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let signingKey = try senderIdentity.signingKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
@@ -149,8 +149,9 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the seal with the sender's Schnorr private key
|
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||||
return try seal.sign(with: senderKey)
|
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
||||||
|
return try seal.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
@@ -180,8 +181,9 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the gift wrap with the wrap Schnorr private key
|
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||||
return try giftWrap.sign(with: wrapKey)
|
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
||||||
|
return try giftWrap.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func unwrapGiftWrap(
|
||||||
@@ -414,7 +416,7 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
// Log with explicit UTC and local time for debugging
|
// Log with explicit UTC and local time for debugging
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
//
|
// Removed unnecessary date formatting operations
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
|
|
||||||
@@ -470,16 +472,19 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
// Sign with Schnorr (BIP-340)
|
// Convert to Schnorr key for Nostr signing
|
||||||
|
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
|
||||||
|
|
||||||
|
// Sign with Schnorr
|
||||||
var messageBytes = [UInt8](eventIdHash)
|
var messageBytes = [UInt8](eventIdHash)
|
||||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
|
|
||||||
|
|||||||
@@ -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 = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
|
||||||
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
|
||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = 2.0 // Double each time
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = 10 // Stop after 10 attempts
|
||||||
|
|
||||||
// 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("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from 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("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by 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("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(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("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to 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 = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
filter.limit = 100 // Add a reasonable limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// MARK: - Special Recipients (removed)
|
||||||
|
// Previously defined broadcast identifiers were unused; removed for simplicity.
|
||||||
|
|
||||||
// MARK: - Core Protocol Structures
|
// MARK: - Core Protocol Structures
|
||||||
|
|
||||||
@@ -267,7 +268,8 @@ struct BitchatPacket: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// MARK: - Delivery Acknowledgments (removed)
|
||||||
|
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
|
||||||
|
|
||||||
// MARK: - Read Receipts
|
// MARK: - Read Receipts
|
||||||
|
|
||||||
@@ -359,7 +361,7 @@ struct ReadReceipt: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//
|
// PeerIdentityBinding removed (unused).
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AutocompleteService {
|
|||||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||||
|
|
||||||
private let commands = [
|
private let commands = [
|
||||||
"/msg", "/who", "/clear",
|
"/msg", "/who", "/clear", "/help",
|
||||||
"/hug", "/slap", "/fav", "/unfav",
|
"/hug", "/slap", "/fav", "/unfav",
|
||||||
"/block", "/unblock"
|
"/block", "/unblock"
|
||||||
]
|
]
|
||||||
@@ -95,7 +95,7 @@ class AutocompleteService {
|
|||||||
|
|
||||||
private func needsArgument(command: String) -> Bool {
|
private func needsArgument(command: String) -> Bool {
|
||||||
switch command {
|
switch command {
|
||||||
case "/who", "/clear":
|
case "/who", "/clear", "/help":
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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 = TransportConfig.bleDefaultFragmentSize
|
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead
|
||||||
private let maxMessageLength = InputValidator.Limits.maxMessageLength
|
private let maxMessageLength = 10_000
|
||||||
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
|
private let messageTTL: UInt8 = 7
|
||||||
// Flood/battery controls
|
// Flood/battery controls
|
||||||
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
|
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies
|
||||||
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
|
private let highDegreeThreshold = 6 // 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 = TransportConfig.bleAnnounceMinInterval
|
private let announceMinInterval: TimeInterval = 1.0
|
||||||
|
|
||||||
// Application state tracking (thread-safe)
|
// Application state tracking (thread-safe)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -113,24 +113,14 @@ final class BLEService: NSObject {
|
|||||||
// Track short-lived traffic bursts to adapt announces/scanning under load
|
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||||||
private var recentPacketTimestamps: [Date] = []
|
private var recentPacketTimestamps: [Date] = []
|
||||||
|
|
||||||
// Ingress link tracking for last-hop suppression
|
|
||||||
private enum LinkID: Hashable {
|
|
||||||
case peripheral(String)
|
|
||||||
case central(String)
|
|
||||||
}
|
|
||||||
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
|
|
||||||
|
|
||||||
// Backpressure-aware write queue per peripheral
|
|
||||||
private var pendingPeripheralWrites: [String: [Data]] = [:]
|
|
||||||
|
|
||||||
// MARK: - Maintenance Timer
|
// MARK: - Maintenance Timer
|
||||||
|
|
||||||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||||||
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 = TransportConfig.bleMaxCentralLinks
|
private let maxCentralLinks = 6
|
||||||
private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval
|
private let connectRateLimitInterval: TimeInterval = 0.5
|
||||||
private var lastGlobalConnectAttempt: Date = .distantPast
|
private var lastGlobalConnectAttempt: Date = .distantPast
|
||||||
private struct ConnectionCandidate {
|
private struct ConnectionCandidate {
|
||||||
let peripheral: CBPeripheral
|
let peripheral: CBPeripheral
|
||||||
@@ -142,13 +132,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 = TransportConfig.bleDynamicRSSIThresholdDefault
|
private var dynamicRSSIThreshold: Int = -90
|
||||||
|
|
||||||
// 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 = TransportConfig.bleDutyOnDuration
|
private var dutyOnDuration: TimeInterval = 5
|
||||||
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
|
private var dutyOffDuration: TimeInterval = 10
|
||||||
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)
|
||||||
@@ -167,88 +157,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
|
||||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
|
||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
|
||||||
guard n > 0 else { return 0 }
|
|
||||||
if n <= 2 { return n }
|
|
||||||
// approx ceil(log2(n)) + 1 without floating point
|
|
||||||
var v = n - 1
|
|
||||||
var bits = 0
|
|
||||||
while v > 0 { v >>= 1; bits += 1 }
|
|
||||||
return min(n, max(1, bits + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
|
||||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
|
||||||
// Stable order by SHA256(seed || "::" || id)
|
|
||||||
var scored: [(score: [UInt8], id: String)] = []
|
|
||||||
for id in ids {
|
|
||||||
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
|
|
||||||
let digest = Array(SHA256.hash(data: msg))
|
|
||||||
scored.append((digest, id))
|
|
||||||
}
|
|
||||||
scored.sort { a, b in
|
|
||||||
for i in 0..<min(a.score.count, b.score.count) {
|
|
||||||
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
|
|
||||||
}
|
|
||||||
return a.id < b.id
|
|
||||||
}
|
|
||||||
return Set(scored.prefix(k).map { $0.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
|
||||||
// BLE operations run on bleQueue; keep queue affinity
|
|
||||||
bleQueue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let uuid = peripheral.identifier.uuidString
|
|
||||||
if peripheral.canSendWriteWithoutResponse {
|
|
||||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
|
||||||
} else {
|
|
||||||
self.collectionsQueue.async(flags: .barrier) {
|
|
||||||
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
|
||||||
let uuid = peripheral.identifier.uuidString
|
|
||||||
bleQueue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
|
|
||||||
var queueCopy: [Data] = []
|
|
||||||
self.collectionsQueue.sync {
|
|
||||||
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
|
|
||||||
}
|
|
||||||
guard !queueCopy.isEmpty else { return }
|
|
||||||
var sent = 0
|
|
||||||
for item in queueCopy {
|
|
||||||
if peripheral.canSendWriteWithoutResponse {
|
|
||||||
peripheral.writeValue(item, for: ch, type: .withoutResponse)
|
|
||||||
sent += 1
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sent > 0 {
|
|
||||||
self.collectionsQueue.async(flags: .barrier) {
|
|
||||||
var q = self.pendingPeripheralWrites[uuid] ?? []
|
|
||||||
if sent <= q.count {
|
|
||||||
q.removeFirst(sent)
|
|
||||||
} else {
|
|
||||||
q.removeAll()
|
|
||||||
}
|
|
||||||
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer snapshots publisher (non-UI convenience)
|
// MARK: - Peer snapshots publisher (non-UI convenience)
|
||||||
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
@@ -257,15 +165,20 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||||
collectionsQueue.sync {
|
collectionsQueue.sync {
|
||||||
let snapshot = Array(peers.values)
|
// Compute nickname collision counts for connected peers
|
||||||
let resolvedNames = PeerDisplayNameResolver.resolve(
|
let connected = peers.values.filter { $0.isConnected }
|
||||||
snapshot.map { ($0.id, $0.nickname, $0.isConnected) },
|
var counts: [String: Int] = [:]
|
||||||
selfNickname: myNickname
|
for p in connected { counts[p.nickname, default: 0] += 1 }
|
||||||
)
|
// Include our own nickname in collision counts so remote matching ours gets suffixed
|
||||||
return snapshot.map { info in
|
counts[myNickname, default: 0] += 1
|
||||||
TransportPeerSnapshot(
|
return peers.values.map { info in
|
||||||
|
var display = info.nickname
|
||||||
|
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
|
||||||
|
display += "#" + String(info.id.prefix(4))
|
||||||
|
}
|
||||||
|
return TransportPeerSnapshot(
|
||||||
id: info.id,
|
id: info.id,
|
||||||
nickname: resolvedNames[info.id] ?? info.nickname,
|
nickname: display,
|
||||||
isConnected: info.isConnected,
|
isConnected: info.isConnected,
|
||||||
noisePublicKey: info.noisePublicKey,
|
noisePublicKey: info.noisePublicKey,
|
||||||
lastSeen: info.lastSeen
|
lastSeen: info.lastSeen
|
||||||
@@ -346,9 +259,7 @@ 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() + TransportConfig.bleMaintenanceInterval,
|
timer.schedule(deadline: .now() + 10.0, repeating: 10.0, leeway: .seconds(1))
|
||||||
repeating: TransportConfig.bleMaintenanceInterval,
|
|
||||||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
|
||||||
timer.setEventHandler { [weak self] in
|
timer.setEventHandler { [weak self] in
|
||||||
self?.performMaintenance()
|
self?.performMaintenance()
|
||||||
}
|
}
|
||||||
@@ -430,7 +341,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() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +369,7 @@ final class BLEService: NSObject {
|
|||||||
// Send to peripherals we're connected to as central
|
// Send to peripherals we're connected to as central
|
||||||
for state in peripherals.values where state.isConnected {
|
for state in peripherals.values where state.isConnected {
|
||||||
if let characteristic = state.characteristic {
|
if let characteristic = state.characteristic {
|
||||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +380,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Give leave message a moment to send
|
// Give leave message a moment to send
|
||||||
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
|
||||||
// Clear pending notifications
|
// Clear pending notifications
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -511,9 +422,22 @@ 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 }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
// Count collisions by nickname (include our own nickname)
|
||||||
return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
|
var counts: [String: Int] = [:]
|
||||||
|
for (_, info) in connected { counts[info.nickname, default: 0] += 1 }
|
||||||
|
counts[myNickname, default: 0] += 1
|
||||||
|
// Build map with suffix for collisions
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for (id, info) in connected {
|
||||||
|
var name = info.nickname
|
||||||
|
if (counts[info.nickname] ?? 0) > 1 {
|
||||||
|
name += "#" + String(id.prefix(4))
|
||||||
|
}
|
||||||
|
result[id] = name
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,7 +638,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Removed unused getPeers(): use getPeerNicknames() from Transport
|
||||||
|
|
||||||
// MARK: - Private Message Handling
|
// MARK: - Private Message Handling
|
||||||
|
|
||||||
@@ -953,7 +877,7 @@ final class BLEService: NSObject {
|
|||||||
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
|
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
|
||||||
state.isConnected,
|
state.isConnected,
|
||||||
let characteristic = state.characteristic {
|
let characteristic = state.characteristic {
|
||||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
sentEncrypted = true
|
sentEncrypted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +889,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 < TransportConfig.blePendingNotificationsCapCount {
|
if self.pendingNotifications.count < 20 {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -984,10 +908,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
|
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
|
||||||
// Determine last-hop link for this message to avoid echoing back
|
|
||||||
let messageID = makeMessageID(for: packet)
|
|
||||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
|
||||||
|
|
||||||
let states = snapshotPeripheralStates()
|
let states = snapshotPeripheralStates()
|
||||||
var minCentralWriteLen: Int?
|
var minCentralWriteLen: Int?
|
||||||
for s in states where s.isConnected {
|
for s in states where s.isConnected {
|
||||||
@@ -1012,54 +932,15 @@ final class BLEService: NSObject {
|
|||||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link
|
// Writes to connected peripherals
|
||||||
let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }
|
|
||||||
let subscribedCentrals: [CBCentral]
|
|
||||||
var centralIDs: [String] = []
|
|
||||||
if let _ = characteristic {
|
|
||||||
let (centrals, _) = snapshotSubscribedCentrals()
|
|
||||||
subscribedCentrals = centrals
|
|
||||||
centralIDs = centrals.map { $0.identifier.uuidString }
|
|
||||||
} else {
|
|
||||||
subscribedCentrals = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude ingress link
|
|
||||||
var allowedPeripheralIDs = connectedPeripheralIDs
|
|
||||||
var allowedCentralIDs = centralIDs
|
|
||||||
if let ingress = ingressLink {
|
|
||||||
switch ingress {
|
|
||||||
case .peripheral(let id):
|
|
||||||
allowedPeripheralIDs.removeAll { $0 == id }
|
|
||||||
case .central(let id):
|
|
||||||
allowedCentralIDs.removeAll { $0 == id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
|
||||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
|
|
||||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
|
||||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
|
||||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
|
||||||
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes to selected connected peripherals
|
|
||||||
for s in states where s.isConnected {
|
for s in states where s.isConnected {
|
||||||
let pid = s.peripheral.identifier.uuidString
|
|
||||||
guard selectedPeripheralIDs.contains(pid) else { continue }
|
|
||||||
if let ch = s.characteristic {
|
if let ch = s.characteristic {
|
||||||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch)
|
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Notify selected subscribed centrals
|
// Notify all subscribed centrals
|
||||||
if let ch = characteristic {
|
if let ch = characteristic {
|
||||||
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
|
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||||||
if !targets.isEmpty {
|
|
||||||
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,7 +954,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Fire-and-forget principle: always use .withoutResponse for speed
|
// Fire-and-forget principle: always use .withoutResponse for speed
|
||||||
// CoreBluetooth will handle fragmentation at L2CAP layer
|
// CoreBluetooth will handle fragmentation at L2CAP layer
|
||||||
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||||||
@@ -1096,7 +977,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(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment
|
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment
|
||||||
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
|
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
|
||||||
self?.startScanning()
|
self?.startScanning()
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1008,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 * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
|
let delayMs = index * 6 // ~6ms spacing per fragment
|
||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||||
self?.broadcastPacket(fragmentPacket)
|
self?.broadcastPacket(fragmentPacket)
|
||||||
}
|
}
|
||||||
@@ -1239,10 +1120,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 N timestamps within window
|
// keep last 100 timestamps within 30s window
|
||||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
let cutoff = now.addingTimeInterval(-30)
|
||||||
if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
if self.recentPacketTimestamps.count > 100 {
|
||||||
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100)
|
||||||
}
|
}
|
||||||
self.recentPacketTimestamps.removeAll { $0 < cutoff }
|
self.recentPacketTimestamps.removeAll { $0 < cutoff }
|
||||||
}
|
}
|
||||||
@@ -1281,7 +1162,6 @@ final class BLEService: NSObject {
|
|||||||
ttl: packet.ttl,
|
ttl: packet.ttl,
|
||||||
senderIsSelf: senderID == myPeerID,
|
senderIsSelf: senderID == myPeerID,
|
||||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
degree: degree,
|
degree: degree,
|
||||||
@@ -1606,7 +1486,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 ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
let minInterval = forceSend ? 0.2 : announceMinInterval
|
||||||
|
|
||||||
if timeSinceLastAnnounce < minInterval {
|
if timeSinceLastAnnounce < minInterval {
|
||||||
// Skipping announce (rate limited)
|
// Skipping announce (rate limited)
|
||||||
@@ -1737,14 +1617,12 @@ 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
|
// Discovery mode: keep frequent announces (~10s)
|
||||||
if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) }
|
if elapsed >= 10.0 { 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 >= TransportConfig.bleHighDegreeThreshold ?
|
let base = connectedCount >= 6 ? 90.0 : 45.0
|
||||||
TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse
|
let jitter = connectedCount >= 6 ? 20.0 : 7.5
|
||||||
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) }
|
||||||
}
|
}
|
||||||
@@ -1785,7 +1663,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) > TransportConfig.blePeerInactivityTimeoutSeconds {
|
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 {
|
||||||
// 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
|
||||||
@@ -1825,9 +1703,9 @@ final class BLEService: NSObject {
|
|||||||
// Clean old processed messages efficiently
|
// Clean old processed messages efficiently
|
||||||
messageDeduplicator.cleanup()
|
messageDeduplicator.cleanup()
|
||||||
|
|
||||||
// Clean old fragments (> configured seconds old)
|
// Clean old fragments (> 30 seconds old)
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
|
let cutoff = now.addingTimeInterval(-30)
|
||||||
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)
|
||||||
@@ -1835,8 +1713,8 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean old connection timeout backoff entries (> window)
|
// Clean old connection timeout backoff entries (> 2 minutes)
|
||||||
let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
|
let timeoutCutoff = now.addingTimeInterval(-120)
|
||||||
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)
|
||||||
@@ -1849,15 +1727,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean ingress link records older than configured seconds
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
|
|
||||||
if !self.ingressByMessageID.isEmpty {
|
|
||||||
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateScanningDutyCycle(connectedCount: Int) {
|
private func updateScanningDutyCycle(connectedCount: Int) {
|
||||||
@@ -1877,12 +1746,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 >= TransportConfig.bleHighDegreeThreshold {
|
if connectedCount >= 6 {
|
||||||
dutyOnDuration = TransportConfig.bleDutyOnDurationDense
|
dutyOnDuration = 3
|
||||||
dutyOffDuration = TransportConfig.bleDutyOffDurationDense
|
dutyOffDuration = 15
|
||||||
} else {
|
} else {
|
||||||
dutyOnDuration = TransportConfig.bleDutyOnDuration
|
dutyOnDuration = 5
|
||||||
dutyOffDuration = TransportConfig.bleDutyOffDuration
|
dutyOffDuration = 10
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -1916,25 +1785,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 > TransportConfig.bleIsolationRelaxThresholdSeconds {
|
if elapsed > 60 {
|
||||||
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed
|
dynamicRSSIThreshold = -92
|
||||||
} else {
|
} else {
|
||||||
dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase
|
dynamicRSSIThreshold = -90
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastIsolatedAt = nil
|
lastIsolatedAt = nil
|
||||||
// Base threshold when connected
|
// Base threshold when connected
|
||||||
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
|
var threshold = -90
|
||||||
// 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 > TransportConfig.bleConnectionCandidatesMax {
|
if linkCount >= maxCentralLinks || connectionCandidates.count > 20 {
|
||||||
threshold = TransportConfig.bleRSSIConnectedThreshold
|
threshold = -85
|
||||||
}
|
}
|
||||||
// If we have many recent timeouts, raise further
|
// If we have many recent timeouts, raise further
|
||||||
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count
|
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count
|
||||||
if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold {
|
if recentTimeouts >= 3 {
|
||||||
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
|
threshold = max(threshold, -80)
|
||||||
}
|
}
|
||||||
dynamicRSSIThreshold = threshold
|
dynamicRSSIThreshold = threshold
|
||||||
}
|
}
|
||||||
@@ -1988,9 +1857,7 @@ 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 > TransportConfig.bleConnectionCandidatesMax {
|
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
|
||||||
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,9 +1871,7 @@ 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 > TransportConfig.bleConnectionCandidatesMax {
|
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) }
|
||||||
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2083,7 +1948,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() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
|
bleQueue.asyncAfter(deadline: .now() + 8.0) { [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 }
|
||||||
@@ -2155,7 +2020,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() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
|
bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.startScanning()
|
self?.startScanning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2252,27 +2117,6 @@ extension BLEService {
|
|||||||
// Test-only helper to inject packets into the receive pipeline
|
// Test-only helper to inject packets into the receive pipeline
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
|
||||||
let normalizedID = packet.senderID.hexEncodedString()
|
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
|
||||||
if peers[normalizedID] == nil {
|
|
||||||
peers[normalizedID] = PeerInfo(
|
|
||||||
id: normalizedID,
|
|
||||||
nickname: "TestPeer_\(fromPeerID.prefix(4))",
|
|
||||||
isConnected: true,
|
|
||||||
noisePublicKey: packet.senderID,
|
|
||||||
signingPublicKey: nil,
|
|
||||||
isVerifiedNickname: true,
|
|
||||||
lastSeen: Date()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
var p = peers[normalizedID]!
|
|
||||||
p.isConnected = true
|
|
||||||
p.isVerifiedNickname = true
|
|
||||||
p.lastSeen = Date()
|
|
||||||
peers[normalizedID] = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
handleReceivedPacket(packet, from: fromPeerID)
|
handleReceivedPacket(packet, from: fromPeerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -2353,7 +2197,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() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2376,9 +2220,7 @@ 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 {
|
||||||
// Avoid dumping entire payload; log size and short prefix for diagnostics
|
SecureLogger.log("❌ Failed to decode notification packet, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))",
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -2403,22 +2245,12 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||||
// Mapping update - direct announce from peer
|
// Mapping update - direct announce from peer
|
||||||
}
|
}
|
||||||
// Record ingress link for last-hop suppression and process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
|
||||||
}
|
|
||||||
// Process the announce packet regardless of whether we updated the mapping
|
// Process the announce packet regardless of whether we updated the mapping
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
} else {
|
} else {
|
||||||
// For non-announce packets, DO NOT update mappings
|
// For non-announce packets, DO NOT update mappings
|
||||||
// These could be relayed packets from other peers
|
// These could be relayed packets from other peers
|
||||||
// Always use the packet's original senderID
|
// Always use the packet's original senderID
|
||||||
// Record ingress link for last-hop suppression and process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
|
||||||
}
|
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2433,8 +2265,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||||
// Resume queued writes for this peripheral
|
// Suppress verbose ready logs
|
||||||
drainPendingWrites(for: peripheral)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||||
@@ -2515,7 +2346,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() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2658,30 +2489,19 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
if packet.type == MessageType.announce.rawValue {
|
if packet.type == MessageType.announce.rawValue {
|
||||||
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
|
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
|
||||||
// Record ingress link for last-hop suppression then process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
|
||||||
}
|
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
} else {
|
} else {
|
||||||
// Record ingress link for last-hop suppression then process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
|
|
||||||
}
|
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
}
|
}
|
||||||
} 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 > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
|
if combined.count > 1_000_000 { // 1MB 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 {
|
||||||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
SecureLogger.log("❌ Failed to decode packet from central, full data: \(raw.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error)
|
||||||
SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ class CommandProcessor {
|
|||||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||||
|
|
||||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
|
||||||
let inGeoPublic: Bool = {
|
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
|
||||||
case .mesh: return false
|
|
||||||
case .location: return true
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
return handleMessage(args)
|
return handleMessage(args)
|
||||||
@@ -58,14 +49,11 @@ class CommandProcessor {
|
|||||||
case "/unblock":
|
case "/unblock":
|
||||||
return handleUnblock(args)
|
return handleUnblock(args)
|
||||||
case "/fav":
|
case "/fav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
|
||||||
return handleFavorite(args, add: true)
|
return handleFavorite(args, add: true)
|
||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
//
|
|
||||||
case "/help", "/h":
|
case "/help", "/h":
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return handleHelp()
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
@@ -97,34 +85,19 @@ class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleWho() -> CommandResult {
|
private func handleWho() -> CommandResult {
|
||||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
|
||||||
case .location(let ch):
|
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
|
||||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let names = people.map { $0.displayName }
|
|
||||||
if names.isEmpty { return .success(message: "no one else is online right now") }
|
|
||||||
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
|
||||||
case .mesh:
|
|
||||||
// Mesh context: show connected peer nicknames
|
|
||||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||||
return .success(message: "no one else is online right now")
|
return .success(message: "no one else is online right now")
|
||||||
}
|
}
|
||||||
|
|
||||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||||
return .success(message: "online: \(onlineList)")
|
return .success(message: "online: \(onlineList)")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||||
} else {
|
} else {
|
||||||
chatViewModel?.clearCurrentPublicTimeline()
|
chatViewModel?.messages.removeAll()
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -192,8 +165,12 @@ class CommandProcessor {
|
|||||||
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = chatViewModel {
|
if let vm = chatViewModel {
|
||||||
|
#if os(iOS)
|
||||||
let visible = vm.visibleGeohashPeople()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
|
#else
|
||||||
|
let visibleIndex: [String: String] = [:]
|
||||||
|
#endif
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
geoNames.append(name)
|
geoNames.append(name)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS)
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||||
@@ -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 = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
cl.distanceFilter = 1000 // 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) {
|
||||||
@@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
teleported = teleportedSet.contains(ch.geohash)
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
} else {
|
} else {
|
||||||
status = CLLocationManager.authorizationStatus()
|
status = CLLocationManager.authorizationStatus()
|
||||||
@@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
func enableLocationChannels() {
|
func enableLocationChannels() {
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
} else {
|
} else {
|
||||||
status = CLLocationManager.authorizationStatus()
|
status = CLLocationManager.authorizationStatus()
|
||||||
@@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
Task { @MainActor in self.permissionState = .restricted }
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
case .denied:
|
case .denied:
|
||||||
Task { @MainActor in self.permissionState = .denied }
|
Task { @MainActor in self.permissionState = .denied }
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
Task { @MainActor in self.permissionState = .authorized }
|
Task { @MainActor in self.permissionState = .authorized }
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -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 = TransportConfig.locationLiveRefreshInterval) {
|
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||||
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()
|
||||||
@@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 14+ / macOS 11+
|
// iOS 14+
|
||||||
@available(iOS 14.0, macOS 11.0, *)
|
@available(iOS 14.0, *)
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
updatePermissionState(from: manager.authorizationStatus)
|
updatePermissionState(from: manager.authorizationStatus)
|
||||||
if case .authorized = permissionState {
|
if case .authorized = permissionState {
|
||||||
@@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
case .notDetermined: newState = .notDetermined
|
case .notDetermined: newState = .notDetermined
|
||||||
case .restricted: newState = .restricted
|
case .restricted: newState = .restricted
|
||||||
case .denied: newState = .denied
|
case .denied: newState = .denied
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
|
||||||
@unknown default: newState = .restricted
|
@unknown default: newState = .restricted
|
||||||
}
|
}
|
||||||
Task { @MainActor in self.permissionState = newState }
|
Task { @MainActor in self.permissionState = newState }
|
||||||
@@ -256,4 +256,5 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -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 = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
var myPeerID: String { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
@@ -48,7 +48,16 @@ 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
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
// Resolve favorite by full noise key or by short peerID fallback
|
||||||
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||||
guard let 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)
|
||||||
@@ -96,7 +105,15 @@ 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
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: item.peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, item.peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
|
||||||
guard let 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)
|
||||||
@@ -132,7 +149,15 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
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 }
|
||||||
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))…",
|
||||||
@@ -158,26 +183,18 @@ 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
|
||||||
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 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)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = 1337
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: String) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ struct RelayController {
|
|||||||
static func decide(ttl: UInt8,
|
static func decide(ttl: UInt8,
|
||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
@@ -20,17 +19,7 @@ struct RelayController {
|
|||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||||
|
|
||||||
// For session-critical or directed traffic, be deterministic and reliable
|
// Degree-aware probability to reduce floods in dense graphs
|
||||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
|
||||||
// Always relay with no TTL cap for these types
|
|
||||||
let newTTL = (ttl &- 1)
|
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
|
||||||
let delayMs = Int.random(in: delayRange)
|
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
|
||||||
let baseProb: Double
|
let baseProb: Double
|
||||||
switch degree {
|
switch degree {
|
||||||
case 0...2: baseProb = 1.0
|
case 0...2: baseProb = 1.0
|
||||||
@@ -39,22 +28,20 @@ struct RelayController {
|
|||||||
case 7...9: baseProb = 0.55
|
case 7...9: baseProb = 0.55
|
||||||
default: baseProb = 0.45
|
default: baseProb = 0.45
|
||||||
}
|
}
|
||||||
let prob = baseProb
|
var prob = baseProb
|
||||||
|
if isHandshake { prob = max(0.3, baseProb - 0.2) }
|
||||||
|
|
||||||
|
// Sample a forwarding decision
|
||||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||||
|
|
||||||
// TTL clamping in dense graphs (only for broadcast)
|
// TTL clamping in dense graphs
|
||||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let clamped = max(1, min(ttl, ttlCap))
|
||||||
let newTTL = clamped &- 1
|
let newTTL = clamped &- 1
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Short jitter to desynchronize rebroadcasts
|
||||||
let delayMs: Int
|
let delayMs = Int.random(in: 20...80)
|
||||||
switch degree {
|
|
||||||
case 0...2: delayMs = Int.random(in: 40...100)
|
|
||||||
case 3...5: delayMs = Int.random(in: 60...150)
|
|
||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
|
||||||
default: delayMs = Int.random(in: 100...220)
|
|
||||||
}
|
|
||||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: Equatable, Hashable {
|
struct TransportPeerSnapshot {
|
||||||
let id: String
|
let id: String
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -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 = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> 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
|
||||||
|
|||||||
@@ -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 = TransportConfig.compressionThresholdBytes // bytes
|
static let compressionThreshold = 100 // 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? {
|
||||||
|
|||||||
@@ -18,18 +18,14 @@ struct InputValidator {
|
|||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
static func validatePeerID(_ peerID: String) -> Bool {
|
||||||
// Accept short routing IDs (exact 16-hex)
|
// Accept short routing IDs (16-hex)
|
||||||
if PeerIDResolver.isShortID(peerID) { return true }
|
if PeerIDResolver.isShortID(peerID) { return true }
|
||||||
// If length equals short-hex length but isn't valid hex, reject
|
// Accept full Noise key hex (64-hex)
|
||||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
|
||||||
// Accept full Noise key hex (exact 64-hex)
|
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||||
// If length equals full key length but isn't valid hex, reject
|
// Internal format: alphanumeric + dash/underscore up to 64
|
||||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
|
||||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
return !peerID.isEmpty &&
|
return !peerID.isEmpty &&
|
||||||
peerID.count < Limits.maxPeerIDLength &&
|
peerID.count <= Limits.maxPeerIDLength &&
|
||||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
private let maxAge: TimeInterval = 300 // 5 minutes
|
||||||
private let maxCount = TransportConfig.messageDedupMaxCount
|
private let maxCount = 1000
|
||||||
|
|
||||||
/// 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,3 +84,4 @@ final class MessageDeduplicator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 = TransportConfig.uiSenderRateBucketCapacity
|
private let senderBucketCapacity: Double = 5
|
||||||
private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second
|
private let senderBucketRefill: Double = 1 // tokens per second
|
||||||
private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity
|
private let contentBucketCapacity: Double = 3
|
||||||
private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second
|
private let contentBucketRefill: Double = 0.5 // 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(TransportConfig.contentKeyPrefixLength))
|
let prefix = String(collapsed.prefix(256))
|
||||||
// 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 = TransportConfig.contentLRUCap
|
private let contentLRUCap = 2000
|
||||||
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 = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
private let maxMessages = 1337 // 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 = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
private let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes; 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
|
||||||
@@ -297,7 +297,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Missing properties that were removed during refactoring
|
||||||
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
|
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
|
||||||
private var selectedPrivateChatFingerprint: String? = nil
|
private var selectedPrivateChatFingerprint: String? = nil
|
||||||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||||||
@@ -349,15 +349,17 @@ 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 = TransportConfig.uiProcessedNostrEventsCap
|
private let maxProcessedNostrEvents = 2000
|
||||||
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
|
||||||
|
#if os(iOS)
|
||||||
@Published private var activeChannel: ChannelID = .mesh
|
@Published private var activeChannel: ChannelID = .mesh
|
||||||
private var geoSubscriptionID: String? = nil
|
private var geoSubscriptionID: String? = nil
|
||||||
private var geoDmSubscriptionID: String? = nil
|
private var geoDmSubscriptionID: String? = nil
|
||||||
private var currentGeohash: String? = nil
|
private var currentGeohash: String? = nil
|
||||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Caches
|
// MARK: - Caches
|
||||||
|
|
||||||
@@ -386,14 +388,15 @@ 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 = TransportConfig.meshTimelineCap
|
private let meshTimelineCap = 1337
|
||||||
|
#if os(iOS)
|
||||||
// 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 = TransportConfig.geoTimelineCap
|
private let geoTimelineCap = 1337
|
||||||
// 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 = TransportConfig.uiChannelInactivityThresholdSeconds
|
private let channelInactivityThreshold: TimeInterval = 9 * 60
|
||||||
// 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] = []
|
||||||
@@ -402,6 +405,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
|
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
|
||||||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
||||||
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Message Delivery Tracking
|
// MARK: - Message Delivery Tracking
|
||||||
|
|
||||||
@@ -428,11 +432,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 = TransportConfig.basePublicFlushInterval
|
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||||
private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
|
private var dynamicPublicFlushInterval: TimeInterval = 0.08
|
||||||
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 = TransportConfig.uiLateInsertThreshold
|
private let lateInsertThreshold: TimeInterval = 15.0
|
||||||
|
|
||||||
// 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,9 +445,21 @@ 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 (no manual synchronize/verify re-read)
|
// Persist to UserDefaults whenever it changes
|
||||||
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)
|
||||||
@@ -506,7 +522,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() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
if let self = self {
|
if let self = self {
|
||||||
_ = self.getMyFingerprint()
|
_ = self.getMyFingerprint()
|
||||||
}
|
}
|
||||||
@@ -526,7 +542,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: TransportConfig.uiStartupShortSleepNs) // 0.2 seconds
|
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
|
||||||
|
|
||||||
// Set up Nostr message handling directly
|
// Set up Nostr message handling directly
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
@@ -539,7 +555,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: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000)) // 2 seconds
|
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||||
self.isStartupPhase = false
|
self.isStartupPhase = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +592,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
self.cancellables.insert(cancellable)
|
self.cancellables.insert(cancellable)
|
||||||
|
|
||||||
// Resubscribe geohash on relay reconnect
|
// Resubscribe geohash on relay reconnect (iOS only)
|
||||||
|
#if os(iOS)
|
||||||
if let relayMgr = self.nostrRelayManager {
|
if let relayMgr = self.nostrRelayManager {
|
||||||
relayMgr.$isConnected
|
relayMgr.$isConnected
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@@ -590,11 +607,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
.store(in: &self.cancellables)
|
.store(in: &self.cancellables)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up Noise encryption callbacks
|
// Set up Noise encryption callbacks
|
||||||
setupNoiseCallbacks()
|
setupNoiseCallbacks()
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
// Observe location channel selection
|
// Observe location channel selection
|
||||||
LocationChannelManager.shared.$selectedChannel
|
LocationChannelManager.shared.$selectedChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@@ -609,6 +628,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Request notification permission
|
// Request notification permission
|
||||||
NotificationService.shared.requestAuthorization()
|
NotificationService.shared.requestAuthorization()
|
||||||
@@ -691,9 +711,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
// No need to force UserDefaults synchronization
|
// Force immediate save
|
||||||
|
userDefaults.synchronize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
// Resubscribe to the active geohash channel without clearing timeline
|
// Resubscribe to the active geohash channel without clearing timeline
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resubscribeCurrentGeohash() {
|
private func resubscribeCurrentGeohash() {
|
||||||
@@ -707,15 +729,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
// Unsubscribe + resubscribe
|
// Unsubscribe + resubscribe
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
ch.geohash,
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
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 }
|
||||||
@@ -733,9 +748,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(TransportConfig.nostrConvKeyPrefixLength))
|
let key16 = "nostr_" + String(event.pubkey.prefix(16))
|
||||||
self.nostrKeyMapping[key16] = event.pubkey
|
self.nostrKeyMapping[key16] = event.pubkey
|
||||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
let key8 = "nostr:" + String(event.pubkey.prefix(8))
|
||||||
self.nostrKeyMapping[key8] = event.pubkey
|
self.nostrKeyMapping[key8] = event.pubkey
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
// Update participants last-seen for this pubkey
|
||||||
@@ -762,7 +777,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
senderPeerID: "nostr:\(event.pubkey.prefix(8))",
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -777,7 +792,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(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400))
|
||||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if self.processedNostrEvents.contains(giftWrap.id) { return }
|
if self.processedNostrEvents.contains(giftWrap.id) { return }
|
||||||
@@ -789,7 +804,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(TransportConfig.nostrConvKeyPrefixLength))
|
let convKey = "nostr_" + String(senderPubkey.prefix(16))
|
||||||
self.nostrKeyMapping[convKey] = senderPubkey
|
self.nostrKeyMapping[convKey] = senderPubkey
|
||||||
switch noisePayload.type {
|
switch noisePayload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
@@ -882,6 +897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Nickname Management
|
// MARK: - Nickname Management
|
||||||
|
|
||||||
@@ -897,7 +913,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
func saveNickname() {
|
func saveNickname() {
|
||||||
userDefaults.set(nickname, forKey: nicknameKey)
|
userDefaults.set(nickname, forKey: nicknameKey)
|
||||||
// Persist nickname; no need to force synchronize
|
userDefaults.synchronize() // Force immediate save
|
||||||
|
|
||||||
// Send announce with new nickname to all peers
|
// Send announce with new nickname to all peers
|
||||||
meshService.sendBroadcastAnnounce()
|
meshService.sendBroadcastAnnounce()
|
||||||
@@ -1089,7 +1105,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() + TransportConfig.uiReadReceiptRetryLongSeconds) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
self?.markPrivateMessagesAsRead(from: peerID)
|
self?.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1209,14 +1225,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let mentions = parseMentions(from: content)
|
let mentions = parseMentions(from: content)
|
||||||
|
|
||||||
// Add message to local display
|
// Add message to local display
|
||||||
|
#if os(iOS)
|
||||||
var displaySender = nickname
|
var displaySender = nickname
|
||||||
var localSenderPeerID = meshService.myPeerID
|
var localSenderPeerID = meshService.myPeerID
|
||||||
if case .location(let ch) = activeChannel,
|
if case .location(let ch) = activeChannel,
|
||||||
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(TransportConfig.nostrShortKeyDisplayLength))"
|
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))"
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
let displaySender = nickname
|
||||||
|
let localSenderPeerID = meshService.myPeerID
|
||||||
|
#endif
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: displaySender,
|
sender: displaySender,
|
||||||
@@ -1236,6 +1257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let ckey = normalizedContentKey(message.content)
|
let ckey = normalizedContentKey(message.content)
|
||||||
recordContentKey(ckey, timestamp: message.timestamp)
|
recordContentKey(ckey, timestamp: message.timestamp)
|
||||||
// Persist to channel-specific timelines
|
// Persist to channel-specific timelines
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
meshTimeline.append(message)
|
meshTimeline.append(message)
|
||||||
@@ -1246,19 +1268,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
geoTimelines[ch.geohash] = arr
|
geoTimelines[ch.geohash] = arr
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
meshTimeline.append(message)
|
||||||
|
trimMeshTimelineIfNeeded()
|
||||||
|
#endif
|
||||||
trimMessagesIfNeeded()
|
trimMessagesIfNeeded()
|
||||||
|
|
||||||
// Force immediate UI update for user's own messages
|
// Force immediate UI update for user's own messages
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
|
|
||||||
// Update channel activity time on send
|
// Update channel activity time on send
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
lastPublicActivityAt["mesh"] = Date()
|
lastPublicActivityAt["mesh"] = Date()
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
if case .location(let ch) = activeChannel {
|
if case .location(let ch) = activeChannel {
|
||||||
// Send to geohash channel via Nostr ephemeral
|
// Send to geohash channel via Nostr ephemeral
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -1271,10 +1300,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
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 {
|
||||||
@@ -1300,10 +1326,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Send via mesh with mentions
|
// Send via mesh with mentions
|
||||||
meshService.sendMessage(content, mentions: mentions)
|
meshService.sendMessage(content, mentions: mentions)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
// Send via mesh with mentions (non-iOS)
|
||||||
|
meshService.sendMessage(content, mentions: mentions)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
@MainActor
|
@MainActor
|
||||||
private func switchLocationChannel(to channel: ChannelID) {
|
private func switchLocationChannel(to channel: ChannelID) {
|
||||||
// Flush pending public buffer to avoid cross-channel bleed
|
// Flush pending public buffer to avoid cross-channel bleed
|
||||||
@@ -1346,21 +1376,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
||||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||||
|
#if os(iOS)
|
||||||
if LocationChannelManager.shared.teleported {
|
if LocationChannelManager.shared.teleported {
|
||||||
let key = id.publicKeyHex.lowercased()
|
let key = id.publicKeyHex.lowercased()
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
teleportedGeo = teleportedGeo.union([key])
|
||||||
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
let subID = "geo-\(ch.geohash)"
|
let subID = "geo-\(ch.geohash)"
|
||||||
geoSubscriptionID = subID
|
geoSubscriptionID = subID
|
||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
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 }
|
||||||
@@ -1402,9 +1430,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(TransportConfig.nostrConvKeyPrefixLength))
|
let key16 = "nostr_" + String(event.pubkey.prefix(16))
|
||||||
self.nostrKeyMapping[key16] = event.pubkey
|
self.nostrKeyMapping[key16] = event.pubkey
|
||||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
let key8 = "nostr:" + String(event.pubkey.prefix(8))
|
||||||
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)
|
||||||
@@ -1427,7 +1455,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
senderPeerID: "nostr:\(event.pubkey.prefix(8))",
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -1445,7 +1473,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(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400))
|
||||||
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
|
||||||
@@ -1562,10 +1590,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
//
|
// Presence announcement removed; we will tag actual chat events instead
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Geohash Participants
|
// MARK: - Geohash Participants (iOS)
|
||||||
|
#if os(iOS)
|
||||||
struct GeoPerson: Identifiable, Equatable {
|
struct GeoPerson: Identifiable, Equatable {
|
||||||
let id: String // pubkey hex (lowercased)
|
let id: String // pubkey hex (lowercased)
|
||||||
let displayName: String
|
let displayName: String
|
||||||
@@ -1594,7 +1623,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(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-5 * 60)
|
||||||
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 }
|
||||||
@@ -1623,13 +1652,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
geoParticipantsTimer?.invalidate()
|
geoParticipantsTimer?.invalidate()
|
||||||
geoParticipantsTimer = nil
|
geoParticipantsTimer = nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Public helpers
|
// MARK: - Public helpers (iOS)
|
||||||
|
#if os(iOS)
|
||||||
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
|
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
|
||||||
@MainActor
|
@MainActor
|
||||||
func visibleGeohashPeople() -> [GeoPerson] {
|
func visibleGeohashPeople() -> [GeoPerson] {
|
||||||
guard let gh = currentGeohash else { return [] }
|
guard let gh = currentGeohash else { return [] }
|
||||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-5 * 60)
|
||||||
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) }
|
||||||
@@ -1641,7 +1672,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(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
let cutoff = Date().addingTimeInterval(-5 * 60)
|
||||||
let map = geoParticipants[geohash] ?? [:]
|
let map = geoParticipants[geohash] ?? [:]
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1720,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove geohash DM conversation if exists
|
// Remove geohash DM conversation if exists
|
||||||
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
let convKey = "nostr_" + String(hex.prefix(16))
|
||||||
if privateChats[convKey] != nil {
|
if privateChats[convKey] != nil {
|
||||||
privateChats.removeValue(forKey: convKey)
|
privateChats.removeValue(forKey: convKey)
|
||||||
unreadPrivateMessages.remove(convKey)
|
unreadPrivateMessages.remove(convKey)
|
||||||
@@ -1715,7 +1746,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let toAdd = desired.subtracting(current)
|
let toAdd = desired.subtracting(current)
|
||||||
let toRemove = current.subtracting(desired)
|
let toRemove = current.subtracting(desired)
|
||||||
|
|
||||||
//
|
// Unsubscribe removed
|
||||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
geoSamplingSubs.removeValue(forKey: subID)
|
geoSamplingSubs.removeValue(forKey: subID)
|
||||||
@@ -1725,11 +1756,7 @@ 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(
|
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
|
||||||
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 }
|
||||||
@@ -1746,6 +1773,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
|
||||||
geoSamplingSubs.removeAll()
|
geoSamplingSubs.removeAll()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
let suffix = String(pubkeyHex.suffix(4))
|
||||||
@@ -1765,12 +1793,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Helper: display name for current active channel (for notifications)
|
// Helper: display name for current active channel (for notifications)
|
||||||
private func activeChannelDisplayName() -> String {
|
private func activeChannelDisplayName() -> String {
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
return "#mesh"
|
return "#mesh"
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
return "#\(ch.geohash)"
|
return "#\(ch.geohash)"
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
return "#mesh"
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup helper with small memory cap
|
// Dedup helper with small memory cap
|
||||||
@@ -1787,6 +1819,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Sends an encrypted private message to a specific peer.
|
/// Sends an encrypted private message to a specific peer.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -1798,6 +1831,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
// Geohash DM routing: conversation keys start with "nostr_"
|
// Geohash DM routing: conversation keys start with "nostr_"
|
||||||
|
#if os(iOS)
|
||||||
if peerID.hasPrefix("nostr_") {
|
if peerID.hasPrefix("nostr_") {
|
||||||
guard case .location(let ch) = activeChannel else {
|
guard case .location(let ch) = activeChannel else {
|
||||||
addSystemMessage("cannot send: not in a location channel")
|
addSystemMessage("cannot send: not in a location channel")
|
||||||
@@ -1863,6 +1897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Check if blocked
|
// Check if blocked
|
||||||
if unifiedPeerService.isBlocked(peerID) {
|
if unifiedPeerService.isBlocked(peerID) {
|
||||||
@@ -1929,10 +1964,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
// 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(TransportConfig.nostrConvKeyPrefixLength))
|
let convKey = "nostr_" + String(hex.prefix(16))
|
||||||
nostrKeyMapping[convKey] = hex
|
nostrKeyMapping[convKey] = hex
|
||||||
selectedPrivateChatPeer = convKey
|
selectedPrivateChatPeer = convKey
|
||||||
}
|
}
|
||||||
@@ -1954,6 +1990,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return "anon#\(suffix)"
|
return "anon#\(suffix)"
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
/// Add a local system message to a private chat (no network send)
|
/// Add a local system message to a private chat (no network send)
|
||||||
@MainActor
|
@MainActor
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
|
||||||
@@ -2402,12 +2439,14 @@ 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() + TransportConfig.uiAnimationMediumSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
self.markPrivateMessagesAsRead(from: peerID)
|
self.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also resubscribe the current geohash channel if active
|
// Also resubscribe the current geohash channel if active
|
||||||
|
#if os(iOS)
|
||||||
resubscribeCurrentGeohash()
|
resubscribeCurrentGeohash()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -2452,6 +2491,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// In public chat - send to active public channel
|
// In public chat - send to active public channel
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||||
@@ -2467,8 +2507,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
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: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
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 {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
}
|
}
|
||||||
@@ -2480,7 +2525,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
meshService.sendMessage(screenshotMessage, mentions: [])
|
||||||
|
#endif
|
||||||
|
|
||||||
// Show local notification immediately as system message
|
// Show local notification immediately as system message
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
@@ -2495,7 +2542,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func appWillResignActive() {
|
@objc private func appWillResignActive() {
|
||||||
// No-op; avoid forcing synchronize on resign
|
userDefaults.synchronize()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationWillTerminate() {
|
@objc func applicationWillTerminate() {
|
||||||
@@ -2508,14 +2555,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Verify identity key is still there
|
// Verify identity key is still there
|
||||||
_ = KeychainManager.shared.verifyIdentityKeyExists()
|
_ = KeychainManager.shared.verifyIdentityKeyExists()
|
||||||
|
|
||||||
// No need to force synchronize here
|
userDefaults.synchronize()
|
||||||
|
|
||||||
// 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
|
||||||
@@ -2559,6 +2607,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
privateChatManager.markAsRead(from: peerID)
|
privateChatManager.markAsRead(from: peerID)
|
||||||
|
|
||||||
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
|
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
|
||||||
|
#if os(iOS)
|
||||||
if peerID.hasPrefix("nostr_"),
|
if peerID.hasPrefix("nostr_"),
|
||||||
let recipientHex = nostrKeyMapping[peerID],
|
let recipientHex = nostrKeyMapping[peerID],
|
||||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
@@ -2576,6 +2625,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Get the peer's Noise key to check for Nostr messages
|
// Get the peer's Noise key to check for Nostr messages
|
||||||
var noiseKeyHex: String? = nil
|
var noiseKeyHex: String? = nil
|
||||||
@@ -2672,6 +2722,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getPeerIDForNickname(_ nickname: String) -> String? {
|
func getPeerIDForNickname(_ nickname: String) -> String? {
|
||||||
|
#if os(iOS)
|
||||||
// When in a geohash channel, allow resolving by geohash participant nickname
|
// When in a geohash channel, allow resolving by geohash participant nickname
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
case .location:
|
case .location:
|
||||||
@@ -2681,14 +2732,13 @@ 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(TransportConfig.nostrConvKeyPrefixLength))
|
let convKey = "nostr_" + String(pub.prefix(16))
|
||||||
nostrKeyMapping[convKey] = pub
|
nostrKeyMapping[convKey] = pub
|
||||||
return convKey
|
return convKey
|
||||||
}
|
}
|
||||||
default:
|
default: break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
// Fallback to mesh nickname resolution
|
#endif
|
||||||
return unifiedPeerService.getPeerID(for: nickname)
|
return unifiedPeerService.getPeerID(for: nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2760,13 +2810,14 @@ 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()
|
||||||
|
|
||||||
// No need to force UserDefaults synchronization
|
// Force immediate 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: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||||
|
|
||||||
// Reinitialize Nostr relay manager with new identity
|
// Reinitialize Nostr relay manager with new identity
|
||||||
nostrRelayManager = NostrRelayManager()
|
nostrRelayManager = NostrRelayManager()
|
||||||
@@ -2795,6 +2846,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
// Build candidate list based on active channel
|
// Build candidate list based on active channel
|
||||||
let peerCandidates: [String] = {
|
let peerCandidates: [String] = {
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
let values = meshService.getPeerNicknames().values
|
let values = meshService.getPeerNicknames().values
|
||||||
@@ -2813,6 +2865,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return Array(tokens)
|
return Array(tokens)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
let values = meshService.getPeerNicknames().values
|
||||||
|
return Array(values.filter { $0 != meshService.myNickname })
|
||||||
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
let (suggestions, range) = autocompleteService.getSuggestions(
|
let (suggestions, range) = autocompleteService.getSuggestions(
|
||||||
@@ -2933,12 +2989,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Determine if this message was sent by self (mesh, geo, or DM)
|
// Determine if this message was sent by self (mesh, geo, or DM)
|
||||||
let isSelf: Bool = {
|
let isSelf: Bool = {
|
||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
// In geohash channels, compare against our per-geohash nostr short ID
|
#if os(iOS)
|
||||||
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(TransportConfig.nostrShortKeyDisplayLength))"
|
return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return spid == meshService.myPeerID
|
return spid == meshService.myPeerID
|
||||||
}
|
}
|
||||||
// Fallback by nickname
|
// Fallback by nickname
|
||||||
@@ -3089,9 +3146,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
|
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
|
||||||
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
||||||
let mySuffix: String? = {
|
let mySuffix: String? = {
|
||||||
|
#if os(iOS)
|
||||||
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return String(id.publicKeyHex.suffix(4))
|
return String(id.publicKeyHex.suffix(4))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return String(meshService.myPeerID.prefix(4))
|
return String(meshService.myPeerID.prefix(4))
|
||||||
}()
|
}()
|
||||||
let isMentionToMe: Bool = {
|
let isMentionToMe: Bool = {
|
||||||
@@ -3124,28 +3183,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let token = String(matchText.dropFirst()).lowercased()
|
let token = String(matchText.dropFirst()).lowercased()
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
||||||
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
|
|
||||||
let attachedToMention: Bool = {
|
|
||||||
// nsRange is the Range<String.Index> for this match within content
|
|
||||||
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
|
|
||||||
if nsRange.lowerBound > content.startIndex {
|
|
||||||
var i = content.index(before: nsRange.lowerBound)
|
|
||||||
while true {
|
|
||||||
let ch = content[i]
|
|
||||||
if ch.isWhitespace || ch.isNewline { break }
|
|
||||||
if ch == "@" { return true }
|
|
||||||
if i == content.startIndex { break }
|
|
||||||
i = content.index(before: i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
var tagStyle = AttributeContainer()
|
var tagStyle = AttributeContainer()
|
||||||
tagStyle.font = isSelf
|
tagStyle.font = isSelf
|
||||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||||
: .system(size: 14, design: .monospaced)
|
: .system(size: 14, design: .monospaced)
|
||||||
tagStyle.foregroundColor = baseColor
|
tagStyle.foregroundColor = baseColor
|
||||||
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
|
if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||||
tagStyle.link = url
|
tagStyle.link = url
|
||||||
tagStyle.underlineStyle = .single
|
tagStyle.underlineStyle = .single
|
||||||
}
|
}
|
||||||
@@ -3487,9 +3530,7 @@ 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) < TransportConfig.uiColorHueAvoidanceDelta {
|
if abs(hue - orange) < 0.05 { hue = fmod(hue + 0.12, 1.0) }
|
||||||
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)
|
||||||
@@ -3545,6 +3586,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearCurrentPublicTimeline() {
|
func clearCurrentPublicTimeline() {
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
@@ -3553,6 +3595,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
geoTimelines[ch.geohash] = []
|
geoTimelines[ch.geohash] = []
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
messages.removeAll()
|
||||||
|
meshTimeline.removeAll()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
|
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
|
||||||
@@ -3617,8 +3663,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return unifiedPeerService.getFingerprint(for: peerID)
|
return unifiedPeerService.getFingerprint(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
private func getFingerprint_old(for peerID: String) -> String? {
|
||||||
|
// Remove debug logging to prevent console spam during view updates
|
||||||
|
|
||||||
|
// First try to get fingerprint from mesh service's peer ID rotation mapping
|
||||||
|
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check noise service (direct Noise session fingerprint)
|
||||||
|
if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) {
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: check local mapping
|
||||||
|
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to resolve nickname for a peer ID through various sources
|
// Helper to resolve nickname for a peer ID through various sources
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -3699,7 +3763,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(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ")
|
let sample = Array(verifiedFingerprints.prefix(3)).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 +4061,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: TransportConfig.uiAsyncMediumSleepNs) // 0.5 seconds
|
try? await Task.sleep(nanoseconds: 500_000_000) // 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)
|
||||||
@@ -4016,7 +4080,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
messageRouter.flushOutbox(for: peerID)
|
messageRouter.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Connection messages removed to reduce chat noise
|
||||||
}
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: String) {
|
func didDisconnectFromPeer(_ peerID: String) {
|
||||||
@@ -4083,7 +4147,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Disconnection messages removed to reduce chat noise
|
||||||
}
|
}
|
||||||
|
|
||||||
func didUpdatePeerList(_ peers: [String]) {
|
func didUpdatePeerList(_ peers: [String]) {
|
||||||
@@ -4367,6 +4431,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Used for emotes where we want a local system-style confirmation instead.
|
// Used for emotes where we want a local system-style confirmation instead.
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPublicRaw(_ content: String) {
|
func sendPublicRaw(_ content: String) {
|
||||||
|
#if os(iOS)
|
||||||
if case .location(let ch) = activeChannel {
|
if case .location(let ch) = activeChannel {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -4390,13 +4455,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
// Default: send over mesh
|
// Default: send over mesh
|
||||||
meshService.sendMessage(content, mentions: [])
|
meshService.sendMessage(content, mentions: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
|
|
||||||
//
|
// Removed inlined Nostr send helpers in favor of MessageRouter
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func setupNostrMessageHandling() {
|
private func setupNostrMessageHandling() {
|
||||||
@@ -4411,7 +4477,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(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
since: Date().addingTimeInterval(-86400) // 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 +4548,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(TransportConfig.nostrConvKeyPrefixLength))
|
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(16))
|
||||||
|
|
||||||
switch noisePayload.type {
|
switch noisePayload.type {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
@@ -4727,7 +4793,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return Data(base64Encoded: str)
|
return Data(base64Encoded: str)
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Removed local TLV decoder; using PrivateMessagePacket.decode from Protocols
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
|
||||||
@@ -4805,7 +4871,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(TransportConfig.nostrConvKeyPrefixLength)
|
let tempPeerID = "nostr_" + senderPubkey.prefix(16)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -4992,10 +5058,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
// MARK: - Geohash Nickname Resolution (for /block in geohash)
|
||||||
@MainActor
|
@MainActor
|
||||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
// Look up current visible geohash participants for an exact displayName match
|
// Look up current visible geohash participants for an exact displayName match (iOS only)
|
||||||
|
#if os(iOS)
|
||||||
for p in visibleGeohashPeople() {
|
for p in visibleGeohashPeople() {
|
||||||
if p.displayName == name { return p.id }
|
if p.displayName == name { return p.id }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5033,7 +5101,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(-TransportConfig.uiMigrationCutoffSeconds)
|
let cutoffTime = Date().addingTimeInterval(-24 * 60 * 60)
|
||||||
|
|
||||||
for (oldPeerID, messages) in privateChats {
|
for (oldPeerID, messages) in privateChats {
|
||||||
if oldPeerID != peerID {
|
if oldPeerID != peerID {
|
||||||
@@ -5257,7 +5325,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark other messages as read
|
// Mark other messages as read
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.markPrivateMessagesAsRead(from: peerID)
|
self?.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5297,7 +5365,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
trimMeshTimelineIfNeeded()
|
trimMeshTimelineIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist geochat messages to per-geohash timeline
|
// Persist geochat messages to per-geohash timeline (iOS-only)
|
||||||
|
#if os(iOS)
|
||||||
if isGeo && finalMessage.sender != "system" {
|
if isGeo && finalMessage.sender != "system" {
|
||||||
if let gh = currentGeohash {
|
if let gh = currentGeohash {
|
||||||
var arr = geoTimelines[gh] ?? []
|
var arr = geoTimelines[gh] ?? []
|
||||||
@@ -5306,14 +5375,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
geoTimelines[gh] = arr
|
geoTimelines[gh] = arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Only add message to current timeline if it matches active channel or is system
|
// Only add message to current timeline if it matches active channel or is system
|
||||||
let isSystem = finalMessage.sender == "system"
|
let isSystem = finalMessage.sender == "system"
|
||||||
let channelMatches: Bool = {
|
let channelMatches: Bool = {
|
||||||
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh: return !isGeo || isSystem
|
case .mesh: return !isGeo || isSystem
|
||||||
case .location: return isGeo || isSystem
|
case .location: return isGeo || isSystem
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
// On non-iOS builds, we don't have location channels; accept all
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
}()
|
}()
|
||||||
|
|
||||||
guard channelMatches else { return }
|
guard channelMatches else { return }
|
||||||
@@ -5493,7 +5568,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) * TransportConfig.uiBatchDispatchStaggerSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) {
|
||||||
impactFeedback.impactOccurred()
|
impactFeedback.impactOccurred()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-50
@@ -13,9 +13,19 @@ import UIKit
|
|||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
//
|
// Pre-computed peer data for performance
|
||||||
|
struct PeerDisplayData: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let isFavorite: Bool
|
||||||
|
let isMe: Bool
|
||||||
|
let hasUnreadMessages: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let connectionState: BitchatPeer.ConnectionState
|
||||||
|
let isMutualFavorite: Bool
|
||||||
|
}
|
||||||
|
|
||||||
//
|
// (Link previews removed; URLs are now clickable inline)
|
||||||
|
|
||||||
// MARK: - Main Content View
|
// MARK: - Main Content View
|
||||||
|
|
||||||
@@ -23,7 +33,9 @@ struct ContentView: View {
|
|||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
#if os(iOS)
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
|
#endif
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@@ -100,14 +112,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 > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
if translation > 50 || (translation > 30 && velocity > 300) {
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +133,7 @@ struct ContentView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -151,14 +163,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: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
|
.animation(.easeInOut(duration: 0.25), 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: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showPrivateChat = newValue != nil
|
showPrivateChat = newValue != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,6 +200,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("direct message") {
|
Button("direct message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
|
#if os(iOS)
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.hasPrefix("nostr:") {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
@@ -195,7 +208,10 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
#else
|
||||||
|
viewModel.startPrivateChat(with: peerID)
|
||||||
|
#endif
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -216,6 +232,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("BLOCK", role: .destructive) {
|
Button("BLOCK", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
|
#if os(iOS)
|
||||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
let sender = selectedMessageSender {
|
let sender = selectedMessageSender {
|
||||||
@@ -223,6 +240,9 @@ struct ContentView: View {
|
|||||||
} else if let sender = selectedMessageSender {
|
} else if let sender = selectedMessageSender {
|
||||||
viewModel.sendMessage("/block \(sender)")
|
viewModel.sendMessage("/block \(sender)")
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("cancel", role: .cancel) {}
|
Button("cancel", role: .cancel) {}
|
||||||
@@ -264,12 +284,13 @@ 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] ?? TransportConfig.uiWindowInitialCountPrivate }
|
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
|
||||||
return windowCountPublic
|
return windowCountPublic
|
||||||
}()
|
}()
|
||||||
let windowedMessages = messages.suffix(currentWindowCount)
|
let windowedMessages = messages.suffix(currentWindowCount)
|
||||||
|
|
||||||
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
||||||
|
#if os(iOS)
|
||||||
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 {
|
||||||
@@ -277,6 +298,12 @@ struct ContentView: View {
|
|||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
|
return "mesh"
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||||
|
|
||||||
ForEach(items, id: \.uiID) { item in
|
ForEach(items, id: \.uiID) { item in
|
||||||
@@ -296,11 +323,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 > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && 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 ? TransportConfig.uiLongMessageLineLimit : nil)
|
.lineLimit(isLong && !isExpanded ? 30 : 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 +339,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
|
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && 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 +398,8 @@ 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 = TransportConfig.uiWindowStepCount
|
let step = 200
|
||||||
|
#if os(iOS)
|
||||||
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 {
|
||||||
@@ -379,9 +407,15 @@ struct ContentView: View {
|
|||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
|
return "mesh"
|
||||||
|
}()
|
||||||
|
#endif
|
||||||
let preserveID = "\(contextKey)|\(message.id)"
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
let current = windowCountPrivate[peer] ?? 300
|
||||||
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
|
||||||
@@ -447,6 +481,7 @@ struct ContentView: View {
|
|||||||
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||||
|
#if os(iOS)
|
||||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||||
switch len {
|
switch len {
|
||||||
case 0...2: return .region
|
case 0...2: return .region
|
||||||
@@ -461,6 +496,7 @@ struct ContentView: View {
|
|||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
// Triple-tap to clear current chat
|
// Triple-tap to clear current chat
|
||||||
@@ -473,12 +509,16 @@ struct ContentView: View {
|
|||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
@@ -493,12 +533,16 @@ struct ContentView: View {
|
|||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
@@ -512,12 +556,16 @@ struct ContentView: View {
|
|||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
return "dm:\(peer)|\(last)"
|
return "dm:\(peer)|\(last)"
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
@@ -540,15 +588,19 @@ 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) > TransportConfig.uiScrollThrottleSeconds {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
// Immediate scroll if enough time has passed
|
// Immediate scroll if enough time has passed
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
let count = windowCountPublic
|
let count = windowCountPublic
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -557,14 +609,18 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Schedule a delayed scroll
|
// Schedule a delayed scroll
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
|
#if os(iOS)
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh: return "mesh"
|
case .mesh: return "mesh"
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
let count = windowCountPublic
|
let count = windowCountPublic
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -589,7 +645,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Same throttling for private chats
|
// Same throttling for private chats
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey = "dm:\(peerID)"
|
let contextKey = "dm:\(peerID)"
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
@@ -599,7 +655,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, 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
|
||||||
@@ -611,6 +667,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||||
// When switching to a new geohash channel, scroll to the bottom
|
// When switching to a new geohash channel, scroll to the bottom
|
||||||
guard privatePeer == nil else { return }
|
guard privatePeer == nil else { return }
|
||||||
@@ -619,7 +676,7 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Reset window size
|
// Reset window size
|
||||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
windowCountPublic = 300
|
||||||
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)" }
|
||||||
@@ -629,17 +686,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = privatePeer {
|
if let peerID = privatePeer {
|
||||||
// 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() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,22 +756,18 @@ struct ContentView: View {
|
|||||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Define commands with aliases and syntax
|
// Define commands with aliases and syntax
|
||||||
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
|
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||||
(["/block"], "[nickname]", "block or list blocked peers"),
|
(["/block"], "[nickname]", "block or list blocked peers"),
|
||||||
(["/clear"], nil, "clear chat messages"),
|
(["/clear"], nil, "clear chat messages"),
|
||||||
|
(["/fav"], "<nickname>", "add to favorites"),
|
||||||
|
(["/help"], nil, "show this help"),
|
||||||
(["/hug"], "<nickname>", "send someone a warm hug"),
|
(["/hug"], "<nickname>", "send someone a warm hug"),
|
||||||
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
||||||
(["/slap"], "<nickname>", "slap someone with a trout"),
|
(["/slap"], "<nickname>", "slap someone with a trout"),
|
||||||
(["/unblock"], "<nickname>", "unblock a peer"),
|
(["/unblock"], "<nickname>", "unblock a peer"),
|
||||||
|
(["/unfav"], "<nickname>", "remove from favorites"),
|
||||||
(["/w"], nil, "see who's online")
|
(["/w"], nil, "see who's online")
|
||||||
]
|
]
|
||||||
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
|
|
||||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
|
||||||
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
|
|
||||||
(["/fav"], "<nickname>", "add to favorites"),
|
|
||||||
(["/unfav"], "<nickname>", "remove from favorites")
|
|
||||||
]
|
|
||||||
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
|
|
||||||
|
|
||||||
// Build the display
|
// Build the display
|
||||||
let allCommands = commandInfo
|
let allCommands = commandInfo
|
||||||
@@ -788,25 +842,18 @@ struct ContentView: View {
|
|||||||
// Check for command autocomplete (instant, no debounce needed)
|
// Check for command autocomplete (instant, no debounce needed)
|
||||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||||
// Build context-aware command list
|
// Build context-aware command list
|
||||||
let isGeoPublic: Bool = {
|
let commandDescriptions = [
|
||||||
if case .location = locationManager.selectedChannel { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
|
||||||
var commandDescriptions = [
|
|
||||||
("/block", "block or list blocked peers"),
|
("/block", "block or list blocked peers"),
|
||||||
("/clear", "clear chat messages"),
|
("/clear", "clear chat messages"),
|
||||||
|
("/fav", "add to favorites"),
|
||||||
|
("/help", "show this help"),
|
||||||
("/hug", "send someone a warm hug"),
|
("/hug", "send someone a warm hug"),
|
||||||
("/m", "send private message"),
|
("/m", "send private message"),
|
||||||
("/slap", "slap someone with a trout"),
|
("/slap", "slap someone with a trout"),
|
||||||
("/unblock", "unblock a peer"),
|
("/unblock", "unblock a peer"),
|
||||||
|
("/unfav", "remove from favorites"),
|
||||||
("/w", "see who's online")
|
("/w", "see who's online")
|
||||||
]
|
]
|
||||||
// Only show favorites commands when not in geohash context
|
|
||||||
if !(isGeoPublic || isGeoDM) {
|
|
||||||
commandDescriptions.append(("/fav", "add to favorites"))
|
|
||||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = newValue.lowercased()
|
let input = newValue.lowercased()
|
||||||
|
|
||||||
@@ -859,7 +906,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() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,7 +935,8 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
// Show QR in mesh on all platforms
|
// Show QR only on mesh channel's peer list
|
||||||
|
#if os(iOS)
|
||||||
if case .mesh = locationManager.selectedChannel {
|
if case .mesh = locationManager.selectedChannel {
|
||||||
Button(action: { showVerifySheet = true }) {
|
Button(action: { showVerifySheet = true }) {
|
||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
@@ -897,6 +945,14 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Verification: show my QR or scan a friend")
|
.help("Verification: show my QR or scan a friend")
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: 44) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -909,6 +965,7 @@ struct ContentView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// People section
|
// People section
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
#if os(iOS)
|
||||||
if case .location = locationManager.selectedChannel {
|
if case .location = locationManager.selectedChannel {
|
||||||
GeohashPeopleList(viewModel: viewModel,
|
GeohashPeopleList(viewModel: viewModel,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
@@ -925,7 +982,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: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -937,6 +994,24 @@ struct ContentView: View {
|
|||||||
viewModel.showFingerprint(for: peerID)
|
viewModel.showFingerprint(for: peerID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
MeshPeerList(viewModel: viewModel,
|
||||||
|
textColor: textColor,
|
||||||
|
secondaryTextColor: secondaryTextColor,
|
||||||
|
onTapPeer: { peerID in
|
||||||
|
viewModel.startPrivateChat(with: peerID)
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleFavorite: { peerID in
|
||||||
|
viewModel.toggleFavorite(peerID: peerID)
|
||||||
|
},
|
||||||
|
onShowFingerprint: { peerID in
|
||||||
|
viewModel.showFingerprint(for: peerID)
|
||||||
|
})
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
|
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
|
||||||
@@ -973,7 +1048,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: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
if !showSidebar {
|
if !showSidebar {
|
||||||
if translation < -100 || (translation < -50 && velocity < -500) {
|
if translation < -100 || (translation < -50 && velocity < -500) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -1026,11 +1101,13 @@ struct ContentView: View {
|
|||||||
return (name, "")
|
return (name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute channel-aware people count and color for toolbar (cross-platform)
|
#if os(iOS)
|
||||||
|
// Compute channel-aware people count and color for toolbar
|
||||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .location:
|
case .location:
|
||||||
let n = viewModel.geohashPeople.count
|
let n = viewModel.geohashPeople.count
|
||||||
|
// Use standard green (dark: system green; light: custom darker green)
|
||||||
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
return (n, n > 0 ? standardGreen : Color.secondary)
|
return (n, n > 0 ? standardGreen : Color.secondary)
|
||||||
case .mesh:
|
case .mesh:
|
||||||
@@ -1040,11 +1117,13 @@ struct ContentView: View {
|
|||||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
else if peer.isMutualFavorite { counts.others += 1 }
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
}
|
}
|
||||||
|
// Darker, more neutral blue (less purple hue)
|
||||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
||||||
return (counts.others, color)
|
return (counts.others, color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
private var mainHeaderView: some View {
|
private var mainHeaderView: some View {
|
||||||
@@ -1091,6 +1170,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Channel badge + dynamic spacing + people counter
|
// Channel badge + dynamic spacing + people counter
|
||||||
// Precompute header count and color outside the ViewBuilder expressions
|
// Precompute header count and color outside the ViewBuilder expressions
|
||||||
|
#if os(iOS)
|
||||||
let cc = channelPeopleCountAndColor()
|
let cc = channelPeopleCountAndColor()
|
||||||
let headerCountColor: Color = cc.1
|
let headerCountColor: Color = cc.1
|
||||||
let headerOtherPeersCount: Int = {
|
let headerOtherPeersCount: Int = {
|
||||||
@@ -1099,11 +1179,24 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
return cc.0
|
return cc.0
|
||||||
}()
|
}()
|
||||||
|
#else
|
||||||
|
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||||
|
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||||
|
let isMeshConnected = peer.isConnected
|
||||||
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
|
}
|
||||||
|
let headerOtherPeersCount = peerCounts.others
|
||||||
|
// Darker, more neutral blue (less purple hue)
|
||||||
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
|
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
||||||
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
||||||
|
|
||||||
// Unread indicator (now shown on iOS and macOS)
|
// Unread indicator
|
||||||
|
#if os(iOS)
|
||||||
if viewModel.hasAnyUnreadMessages {
|
if viewModel.hasAnyUnreadMessages {
|
||||||
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
@@ -1138,6 +1231,7 @@ struct ContentView: View {
|
|||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel("location channels")
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
@@ -1153,7 +1247,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: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar.toggle()
|
showSidebar.toggle()
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -1165,9 +1259,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
#if os(iOS)
|
||||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,11 +1299,13 @@ struct ContentView: View {
|
|||||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||||
let privatePeerNick: String = {
|
let privatePeerNick: String = {
|
||||||
if privatePeerID.hasPrefix("nostr_") {
|
if privatePeerID.hasPrefix("nostr_") {
|
||||||
|
#if os(iOS)
|
||||||
// Build geohash DM header: "#<ghash>/@name#abcd"
|
// Build geohash DM header: "#<ghash>/@name#abcd"
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
if case .location(let ch) = locationManager.selectedChannel {
|
||||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
return peer?.displayName ??
|
return peer?.displayName ??
|
||||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||||
@@ -1294,7 +1392,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: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
@@ -1372,7 +1470,23 @@ private struct PaymentChipView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
||||||
|
struct MessageContentView: View {
|
||||||
|
let message: BitchatMessage
|
||||||
|
let viewModel: ChatViewModel
|
||||||
|
let colorScheme: ColorScheme
|
||||||
|
let isMentioned: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(message.content)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.fontWeight(isMentioned ? .bold : .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
// buildTextSegments removed: content is rendered plain.
|
||||||
|
}
|
||||||
|
|
||||||
// Delivery status indicator view
|
// Delivery status indicator view
|
||||||
struct DeliveryStatusView: View {
|
struct DeliveryStatusView: View {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
struct GeohashPeopleList: View {
|
struct GeohashPeopleList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
@@ -28,12 +29,16 @@ struct GeohashPeopleList: View {
|
|||||||
let people = viewModel.visibleGeohashPeople()
|
let people = viewModel.visibleGeohashPeople()
|
||||||
let currentIDs = people.map { $0.id }
|
let currentIDs = people.map { $0.id }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
|
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
|
||||||
let isTeleportedID: (String) -> Bool = { id in
|
let isTeleportedID: (String) -> Bool = { id in
|
||||||
if teleportedSet.contains(id.lowercased()) { return true }
|
if teleportedSet.contains(id.lowercased()) { return true }
|
||||||
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
|
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
let isTeleportedID: (String) -> Bool = { _ in false }
|
||||||
|
#endif
|
||||||
|
|
||||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
let nonTele = displayIDs.filter { !isTeleportedID($0) }
|
let nonTele = displayIDs.filter { !isTeleportedID($0) }
|
||||||
@@ -47,7 +52,11 @@ struct GeohashPeopleList: View {
|
|||||||
let person = personByID[pid]!
|
let person = personByID[pid]!
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let isMe = (person.id == myHex)
|
let isMe = (person.id == myHex)
|
||||||
|
#if os(iOS)
|
||||||
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
||||||
|
#else
|
||||||
|
let teleported = false
|
||||||
|
#endif
|
||||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
@@ -118,8 +127,10 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
// Helper to split a trailing #abcd suffix
|
||||||
|
#if os(iOS)
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
private func splitSuffix(from name: String) -> (String, String) {
|
||||||
guard name.count >= 5 else { return (name, "") }
|
guard name.count >= 5 else { return (name, "") }
|
||||||
let suffix = String(name.suffix(5))
|
let suffix = String(name.suffix(5))
|
||||||
@@ -131,3 +142,4 @@ private func splitSuffix(from name: String) -> (String, String) {
|
|||||||
}
|
}
|
||||||
return (name, "")
|
return (name, "")
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreLocation
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
struct LocationChannelsSheet: View {
|
struct LocationChannelsSheet: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@@ -40,7 +37,11 @@ struct LocationChannelsSheet: View {
|
|||||||
Text("location permission denied. enable in settings to use location channels.")
|
Text("location permission denied. enable in settings to use location channels.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button("open settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -53,7 +54,6 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
#if os(iOS)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -61,21 +61,8 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .automatic) {
|
|
||||||
Button("close") { isPresented = false }
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
|
||||||
#endif
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Refresh channels when opening
|
// Refresh channels when opening
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
@@ -141,12 +128,10 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("geohash", text: $customGeohash)
|
TextField("geohash", text: $customGeohash)
|
||||||
#if os(iOS)
|
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.keyboardType(.asciiCapable)
|
|
||||||
#endif
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.keyboardType(.asciiCapable)
|
||||||
.onChange(of: customGeohash) { newValue in
|
.onChange(of: customGeohash) { newValue in
|
||||||
// Allow only geohash base32 characters, strip '#', limit length
|
// Allow only geohash base32 characters, strip '#', limit length
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
@@ -198,7 +183,9 @@ struct LocationChannelsSheet: View {
|
|||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openSystemLocationSettings()
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text("remove location access")
|
Text("remove location access")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
@@ -356,7 +343,7 @@ extension LocationChannelsSheet {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
let usesMetric: Bool = {
|
let usesMetric: Bool = {
|
||||||
if #available(iOS 16.0, macOS 13.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
return Locale.current.measurementSystem == .metric
|
return Locale.current.measurementSystem == .metric
|
||||||
} else {
|
} else {
|
||||||
return Locale.current.usesMetricSystem
|
return Locale.current.usesMetricSystem
|
||||||
@@ -379,7 +366,7 @@ extension LocationChannelsSheet {
|
|||||||
|
|
||||||
private func bluetoothRangeString() -> String {
|
private func bluetoothRangeString() -> String {
|
||||||
let usesMetric: Bool = {
|
let usesMetric: Bool = {
|
||||||
if #available(iOS 16.0, macOS 13.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
return Locale.current.measurementSystem == .metric
|
return Locale.current.measurementSystem == .metric
|
||||||
} else {
|
} else {
|
||||||
return Locale.current.usesMetricSystem
|
return Locale.current.usesMetricSystem
|
||||||
@@ -403,17 +390,4 @@ extension LocationChannelsSheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Open Settings helper
|
|
||||||
private func openSystemLocationSettings() {
|
|
||||||
#if os(iOS)
|
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.bluetooth</key>
|
<key>com.apple.security.device.bluetooth</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -7,160 +7,160 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Social
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// Modern share extension using UIKit + UTTypes.
|
class ShareViewController: SLComposeServiceViewController {
|
||||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
|
||||||
final class ShareViewController: UIViewController {
|
|
||||||
private let statusLabel: UILabel = {
|
|
||||||
let l = UILabel()
|
|
||||||
l.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
l.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
||||||
l.textAlignment = .center
|
|
||||||
l.numberOfLines = 0
|
|
||||||
l.textColor = .label
|
|
||||||
return l
|
|
||||||
}()
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
// Set placeholder text
|
||||||
view.addSubview(statusLabel)
|
placeholder = "Share to bitchat..."
|
||||||
NSLayoutConstraint.activate([
|
// Set character limit (optional)
|
||||||
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
charactersRemaining = 500
|
||||||
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
||||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
|
||||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
processShare()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
override func isContentValid() -> Bool {
|
||||||
private func processShare() {
|
// Validate that we have text content or attachments
|
||||||
guard let ctx = self.extensionContext,
|
if let text = contentText, !text.isEmpty {
|
||||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
return true
|
||||||
finishWithMessage("Nothing to share")
|
}
|
||||||
|
// Check if we have attachments
|
||||||
|
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||||
|
let attachments = item.attachments,
|
||||||
|
!attachments.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didSelectPost() {
|
||||||
|
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
|
||||||
|
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try content from attributed text first (Safari often passes URL here)
|
|
||||||
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
|
|
||||||
saveAndFinish(url: url, title: item.attributedTitle?.string)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan attachments for URL/text
|
// Get the page title from the compose view or extension item
|
||||||
let providers = item.attachments ?? []
|
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
|
||||||
if providers.isEmpty {
|
|
||||||
// Fallback: use attributed title as plain text
|
|
||||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
|
||||||
saveAndFinish(text: title)
|
|
||||||
} else {
|
|
||||||
finishWithMessage("No shareable content")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load URL or text asynchronously
|
var foundURL: URL? = nil
|
||||||
loadFirstURL(from: providers) { [weak self] url in
|
let group = DispatchGroup()
|
||||||
guard let self = self else { return }
|
|
||||||
if let url = url {
|
|
||||||
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
|
|
||||||
} else {
|
|
||||||
self.loadFirstPlainText(from: providers) { text in
|
|
||||||
if let t = text, !t.isEmpty {
|
|
||||||
// Treat as URL if parseable http(s), else plain text
|
|
||||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
|
||||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
|
||||||
} else {
|
|
||||||
self.saveAndFinish(text: t)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.finishWithMessage("No shareable content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func detectURL(in text: String) -> URL? {
|
// IMPORTANT: Check if the NSExtensionItem itself has a URL
|
||||||
guard !text.isEmpty else { return nil }
|
// Safari often provides the URL as an attributedString with a link
|
||||||
|
if let attributedText = extensionItem.attributedContentText {
|
||||||
|
let text = attributedText.string
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
let range = NSRange(location: 0, length: (text as NSString).length)
|
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
||||||
let match = detector?.matches(in: text, options: [], range: range).first
|
if let firstMatch = matches?.first, let url = firstMatch.url {
|
||||||
return match?.url
|
foundURL = url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
// Only check attachments if we haven't found a URL yet
|
||||||
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
if foundURL == nil {
|
||||||
let grp = DispatchGroup()
|
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
|
||||||
var found: URL?
|
|
||||||
|
|
||||||
for p in providers where found == nil {
|
// Try multiple URL type identifiers that Safari might use
|
||||||
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
|
let urlTypes = [
|
||||||
grp.enter()
|
UTType.url.identifier,
|
||||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
"public.url",
|
||||||
defer { grp.leave() }
|
"public.file-url"
|
||||||
if let u = item as? URL { found = u; return }
|
|
||||||
if let s = item as? String, let u = URL(string: s) { found = u; return }
|
|
||||||
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grp.notify(queue: .main) { completion(found) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
|
||||||
let id = UTType.plainText.identifier
|
|
||||||
let grp = DispatchGroup()
|
|
||||||
var text: String?
|
|
||||||
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
|
|
||||||
grp.enter()
|
|
||||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
|
||||||
defer { grp.leave() }
|
|
||||||
if let s = item as? String { text = s }
|
|
||||||
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
grp.notify(queue: .main) { completion(text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Save + Finish
|
|
||||||
private func saveAndFinish(url: URL, title: String?) {
|
|
||||||
let payload: [String: String] = [
|
|
||||||
"url": url.absoluteString,
|
|
||||||
"title": title ?? url.host ?? "Shared Link"
|
|
||||||
]
|
]
|
||||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
|
||||||
let s = String(data: json, encoding: .utf8) {
|
for urlType in urlTypes {
|
||||||
saveToSharedDefaults(content: s, type: "url")
|
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
|
||||||
finishWithMessage("✓ Shared link to bitchat")
|
group.enter()
|
||||||
} else {
|
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
|
||||||
finishWithMessage("Failed to encode link")
|
defer { group.leave() }
|
||||||
|
|
||||||
|
if let url = item as? URL {
|
||||||
|
foundURL = url
|
||||||
|
} else if let data = item as? Data,
|
||||||
|
let urlString = String(data: data, encoding: .utf8),
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
foundURL = url
|
||||||
|
} else if let string = item as? String,
|
||||||
|
let url = URL(string: string) {
|
||||||
|
foundURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break // Found a URL type, no need to check other types
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAndFinish(text: String) {
|
// Also check for plain text that might be a URL
|
||||||
saveToSharedDefaults(content: text, type: "text")
|
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||||
finishWithMessage("✓ Shared text to bitchat")
|
group.enter()
|
||||||
|
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
|
||||||
|
defer { group.leave() }
|
||||||
|
|
||||||
|
if let text = item as? String {
|
||||||
|
// Check if the text is actually a URL
|
||||||
|
if let url = URL(string: text),
|
||||||
|
(url.scheme == "http" || url.scheme == "https") {
|
||||||
|
foundURL = url
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // End of if foundURL == nil
|
||||||
|
|
||||||
|
// Process after all checks complete
|
||||||
|
group.notify(queue: .main) { [weak self] in
|
||||||
|
if let url = foundURL {
|
||||||
|
// We have a URL! Create the JSON data
|
||||||
|
let urlData: [String: String] = [
|
||||||
|
"url": url.absoluteString,
|
||||||
|
"title": pageTitle ?? url.host ?? "Shared Link"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
|
||||||
|
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||||
|
self?.saveToSharedDefaults(content: jsonString, type: "url")
|
||||||
|
}
|
||||||
|
} else if let title = pageTitle, !title.isEmpty {
|
||||||
|
// No URL found, just share the text
|
||||||
|
self?.saveToSharedDefaults(content: title, type: "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func configurationItems() -> [Any]! {
|
||||||
|
// No configuration items needed
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
// Use app groups to share data between extension and main app
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
// No need to force synchronize; the system persists changes
|
userDefaults.synchronize()
|
||||||
|
|
||||||
|
|
||||||
|
// Force open the main app
|
||||||
|
self.openMainApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishWithMessage(_ msg: String) {
|
private func openMainApp() {
|
||||||
statusLabel.text = msg
|
// Share extensions cannot directly open the containing app
|
||||||
// Complete shortly after showing status
|
// The app will check for shared content when it becomes active
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
// Show success feedback to user
|
||||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
DispatchQueue.main.async {
|
||||||
|
self.textView.text = "✓ Shared to bitchat"
|
||||||
|
self.textView.isEditable = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,10 +21,8 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("Sender pubkey: \(sender.publicKeyHex)")
|
print("Sender pubkey: \(sender.publicKeyHex)")
|
||||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Create a test message
|
// Create a test message
|
||||||
let originalContent = "Hello from NIP-17 test!"
|
let originalContent = "Hello from NIP-17 test!"
|
||||||
@@ -36,10 +34,8 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Decrypt the gift wrap
|
// Decrypt the gift wrap
|
||||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -56,9 +52,7 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
||||||
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
||||||
@@ -81,10 +75,8 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||||
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
||||||
#if DEBUG
|
|
||||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Both should decrypt successfully
|
// Both should decrypt successfully
|
||||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -117,9 +109,7 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
giftWrap: giftWrap,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: wrongRecipient
|
recipientIdentity: wrongRecipient
|
||||||
)) { error in
|
)) { error in
|
||||||
#if DEBUG
|
|
||||||
print("Expected error when decrypting with wrong key: \(error)")
|
print("Expected error when decrypting with wrong key: \(error)")
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// LegacyTestProtocolTypes.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Minimal legacy protocol types used only by tests to simulate old flows.
|
||||||
|
// These are not part of production code anymore.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ProtocolNack {
|
||||||
|
let originalPacketID: String
|
||||||
|
let nackID: String
|
||||||
|
let senderID: String
|
||||||
|
let receiverID: String
|
||||||
|
let packetType: UInt8
|
||||||
|
let reason: String
|
||||||
|
let errorCode: UInt8
|
||||||
|
|
||||||
|
enum ErrorCode: UInt8 {
|
||||||
|
case unknown = 0
|
||||||
|
case decryptionFailed = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
|
||||||
|
self.originalPacketID = originalPacketID
|
||||||
|
self.nackID = UUID().uuidString
|
||||||
|
self.senderID = senderID
|
||||||
|
self.receiverID = receiverID
|
||||||
|
self.packetType = packetType
|
||||||
|
self.reason = reason
|
||||||
|
self.errorCode = errorCode.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBinaryData() -> Data {
|
||||||
|
// Tests don't parse the payload; return a compact encoding for completeness
|
||||||
|
var data = Data()
|
||||||
|
data.appendUUID(originalPacketID)
|
||||||
|
data.appendUUID(nackID)
|
||||||
|
data.append(UInt8(packetType))
|
||||||
|
data.append(UInt8(errorCode))
|
||||||
|
data.appendString(reason)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Command line invocation:
|
||||||
|
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
|
||||||
|
|
||||||
|
Build settings from command line:
|
||||||
|
CODE_SIGNING_ALLOWED = NO
|
||||||
|
ONLY_ACTIVE_ARCH = YES
|
||||||
|
SDKROOT = iphonesimulator18.5
|
||||||
|
|
||||||
|
Resolve Package Graph
|
||||||
|
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
|
||||||
|
Package: swift-secp256k1
|
||||||
|
|
||||||
|
fatalError
|
||||||
Reference in New Issue
Block a user