mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4c596a17 | ||
|
|
96c6fc0c0d | ||
|
|
a7d5b2d7d9 | ||
|
|
680a390b2d | ||
|
|
60b0deee7b | ||
|
|
2f7c0aaaf7 | ||
|
|
7c4c3f1391 | ||
|
|
3c06bd6386 | ||
|
|
acc11101ad | ||
|
|
5fd9140ffa | ||
|
|
e6903456ab | ||
|
|
85f99984c4 |
@@ -71,3 +71,7 @@ __pycache__/
|
|||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
TestResult.xcresult/
|
||||||
|
*.xcresult/
|
||||||
|
build.log
|
||||||
|
*.log
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
|
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
|
||||||
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||||
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||||
|
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||||
|
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||||
|
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||||
@@ -56,6 +59,8 @@
|
|||||||
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
||||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||||
|
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
||||||
|
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
||||||
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
|
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
||||||
@@ -102,11 +107,17 @@
|
|||||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
|
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
|
||||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||||
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
||||||
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
|
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
|
||||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
|
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
|
||||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||||
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||||
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||||
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
|
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
|
||||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||||
@@ -193,6 +204,7 @@
|
|||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
||||||
|
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
|
||||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||||
@@ -208,6 +220,7 @@
|
|||||||
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
||||||
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
|
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
|
||||||
|
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
|
||||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
||||||
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
||||||
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
||||||
@@ -238,8 +251,11 @@
|
|||||||
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
|
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
|
||||||
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
||||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
||||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
||||||
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
@@ -394,6 +410,7 @@
|
|||||||
9A78348821A7D3374607D4E3 /* Utils */ = {
|
9A78348821A7D3374607D4E3 /* Utils */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
|
||||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
|
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
|
||||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
|
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
|
||||||
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
|
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
|
||||||
@@ -428,6 +445,7 @@
|
|||||||
A55126E93155456CAA8D6656 /* Views */ = {
|
A55126E93155456CAA8D6656 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||||
@@ -501,6 +519,8 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||||
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
047502B82E560F690083520F /* RelayController.swift */,
|
047502B82E560F690083520F /* RelayController.swift */,
|
||||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||||
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
||||||
@@ -522,6 +542,7 @@
|
|||||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||||
@@ -712,6 +733,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
|
||||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
|
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -720,6 +742,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 */,
|
||||||
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.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 */,
|
||||||
@@ -774,6 +801,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 */,
|
||||||
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.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 */,
|
||||||
@@ -972,7 +1004,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1003,7 +1035,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1058,7 +1090,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1090,7 +1122,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.1;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1179,7 +1211,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.1;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1272,7 +1304,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.2;
|
MARKETING_VERSION = 1.3.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
+28
-16
@@ -31,6 +31,13 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(chatViewModel)
|
.environmentObject(chatViewModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||||
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@@ -92,30 +99,18 @@ struct BitchatApp: App {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process if shared within last 30 seconds
|
// Only process if shared within configured window
|
||||||
if Date().timeIntervalSince(sharedDate) < 30 {
|
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||||
|
|
||||||
// Clear the shared content
|
// Clear the shared content
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
userDefaults.synchronize()
|
// No need to force synchronize here
|
||||||
|
|
||||||
// Show notification about shared content
|
// Send the shared content immediately on the main queue
|
||||||
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),
|
||||||
@@ -178,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Handle deeplink (e.g., geohash activity)
|
||||||
|
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||||
|
#if os(iOS)
|
||||||
|
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||||
|
#else
|
||||||
|
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
@@ -197,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deep = userInfo["deeplink"] as? String,
|
||||||
|
let gh = deep.components(separatedBy: "/").last {
|
||||||
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show notification in all other cases
|
// Show notification in all other cases
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ enum HandshakeState {
|
|||||||
struct CryptographicIdentity: Codable {
|
struct CryptographicIdentity: Codable {
|
||||||
let fingerprint: String // SHA256 of public key
|
let fingerprint: String // SHA256 of public key
|
||||||
let publicKey: Data // Noise static public key
|
let publicKey: Data // Noise static public key
|
||||||
|
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||||
|
var signingPublicKey: Data? = nil
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
let lastHandshake: Date?
|
||||||
}
|
}
|
||||||
@@ -173,56 +175,7 @@ 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
|
//
|
||||||
|
|||||||
@@ -230,6 +230,91 @@ class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cryptographic Identities
|
||||||
|
|
||||||
|
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||||
|
/// - noisePublicKey: Noise static public key data
|
||||||
|
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||||
|
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||||
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||||
|
// Update keys if changed
|
||||||
|
if existing.publicKey != noisePublicKey {
|
||||||
|
existing = CryptographicIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
publicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||||
|
firstSeen: existing.firstSeen,
|
||||||
|
lastHandshake: now
|
||||||
|
)
|
||||||
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
|
} else {
|
||||||
|
// Update signing key and lastHandshake
|
||||||
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
|
let updated = CryptographicIdentity(
|
||||||
|
fingerprint: existing.fingerprint,
|
||||||
|
publicKey: existing.publicKey,
|
||||||
|
signingPublicKey: existing.signingPublicKey,
|
||||||
|
firstSeen: existing.firstSeen,
|
||||||
|
lastHandshake: now
|
||||||
|
)
|
||||||
|
self.cryptographicIdentities[fingerprint] = updated
|
||||||
|
}
|
||||||
|
// Persist updated state (already assigned in branches above)
|
||||||
|
} else {
|
||||||
|
// New entry
|
||||||
|
let entry = CryptographicIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
publicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey,
|
||||||
|
firstSeen: now,
|
||||||
|
lastHandshake: now
|
||||||
|
)
|
||||||
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally persist claimed nickname into social identity
|
||||||
|
if let claimed = claimedNickname {
|
||||||
|
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
localPetname: nil,
|
||||||
|
claimedNickname: claimed,
|
||||||
|
trustLevel: .unknown,
|
||||||
|
isFavorite: false,
|
||||||
|
isBlocked: false,
|
||||||
|
notes: nil
|
||||||
|
)
|
||||||
|
// Update claimed nickname if changed
|
||||||
|
if identity.claimedNickname != claimed {
|
||||||
|
identity.claimedNickname = claimed
|
||||||
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
|
} else if self.cache.socialIdentities[fingerprint] == nil {
|
||||||
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve cryptographic identity by fingerprint
|
||||||
|
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
||||||
|
queue.sync { cryptographicIdentities[fingerprint] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||||
|
queue.sync {
|
||||||
|
// Defensive: ensure hex and correct length
|
||||||
|
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||||
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
return Array(cache.socialIdentities.values)
|
return Array(cache.socialIdentities.values)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
|
let isReachable: Bool
|
||||||
|
|
||||||
// Favorite-related properties
|
// Favorite-related properties
|
||||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||||
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
// Connection state
|
// Connection state
|
||||||
enum ConnectionState {
|
enum ConnectionState {
|
||||||
case bluetoothConnected
|
case bluetoothConnected
|
||||||
|
case meshReachable // Seen via mesh recently, not directly connected
|
||||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||||
case offline // Not connected via any transport
|
case offline // Not connected via any transport
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
var connectionState: ConnectionState {
|
var connectionState: ConnectionState {
|
||||||
if isConnected {
|
if isConnected {
|
||||||
return .bluetoothConnected
|
return .bluetoothConnected
|
||||||
|
} else if isReachable {
|
||||||
|
return .meshReachable
|
||||||
} else if favoriteStatus?.isMutual == true {
|
} else if favoriteStatus?.isMutual == true {
|
||||||
// Mutual favorites can communicate via Nostr when offline
|
// Mutual favorites can communicate via Nostr when offline
|
||||||
return .nostrAvailable
|
return .nostrAvailable
|
||||||
@@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
switch connectionState {
|
switch connectionState {
|
||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
return "📻" // Radio icon for mesh connection
|
return "📻" // Radio icon for mesh connection
|
||||||
|
case .meshReachable:
|
||||||
|
return "📡" // Antenna for mesh reachable
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
return "🌐" // Purple globe for Nostr
|
return "🌐" // Purple globe for Nostr
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false
|
isConnected: Bool = false,
|
||||||
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
|
self.isReachable = isReachable
|
||||||
|
|
||||||
// Load favorite status - will be set later by the manager
|
// Load favorite status - will be set later by the manager
|
||||||
self.favoriteStatus = nil
|
self.favoriteStatus = nil
|
||||||
@@ -89,219 +97,4 @@ 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 = 60 * 60 * 24 // 24h
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
// Load cached or bundled data synchronously
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
@@ -12,8 +13,9 @@ struct NostrProtocol {
|
|||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
|
case dm = 14 // NIP-17 DM rumor kind
|
||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-17 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ struct NostrProtocol {
|
|||||||
let rumor = NostrEvent(
|
let rumor = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: .dm, // NIP-17: DM rumor kind 14
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
@@ -120,8 +122,8 @@ struct NostrProtocol {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let signingKey = try senderIdentity.signingKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: signingKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
@@ -147,9 +149,8 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
// Sign the seal with the sender's Schnorr private key
|
||||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
return try seal.sign(with: senderKey)
|
||||||
return try seal.sign(with: signingKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
@@ -179,9 +180,8 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
// Sign the gift wrap with the wrap Schnorr private key
|
||||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
return try giftWrap.sign(with: wrapKey)
|
||||||
return try giftWrap.sign(with: signingKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func unwrapGiftWrap(
|
||||||
@@ -227,7 +227,7 @@ struct NostrProtocol {
|
|||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 style)
|
// MARK: - Encryption (NIP-44 v2)
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
@@ -239,33 +239,31 @@ struct NostrProtocol {
|
|||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypting message
|
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
|
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||||
|
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||||
|
|
||||||
// Derived shared secret
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
|
var nonce24 = Data(count: 24)
|
||||||
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate nonce
|
let pt = Data(plaintext.utf8)
|
||||||
let nonce = AES.GCM.Nonce()
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||||
|
|
||||||
// Encrypt
|
// v2: base64url(nonce24 || ciphertext || tag)
|
||||||
let sealed = try AES.GCM.seal(
|
var combined = Data()
|
||||||
plaintext.data(using: .utf8)!,
|
combined.append(nonce24)
|
||||||
using: SymmetricKey(data: sharedSecret),
|
combined.append(sealed.ciphertext)
|
||||||
nonce: nonce
|
combined.append(sealed.tag)
|
||||||
)
|
return "v2:" + base64URLEncode(combined)
|
||||||
|
|
||||||
// Combine nonce + ciphertext + tag
|
|
||||||
var result = Data()
|
|
||||||
result.append(nonce.withUnsafeBytes { Data($0) })
|
|
||||||
result.append(sealed.ciphertext)
|
|
||||||
result.append(sealed.tag)
|
|
||||||
|
|
||||||
return result.base64EncodedString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -273,87 +271,46 @@ struct NostrProtocol {
|
|||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
// Expect NIP-44 v2 format
|
||||||
// Decrypting message
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Data(base64Encoded: ciphertext),
|
guard let data = base64URLDecode(encoded),
|
||||||
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ciphertext data parsed
|
let nonce24 = data.prefix(24)
|
||||||
|
let rest = data.dropFirst(24)
|
||||||
|
let tag = rest.suffix(16)
|
||||||
|
let ct = rest.dropLast(16)
|
||||||
|
|
||||||
// Extract components
|
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||||
let nonceData = data.prefix(12)
|
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||||
let ciphertextData = data.dropFirst(12).dropLast(16)
|
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||||
let tagData = data.suffix(16)
|
let key = try deriveNIP44V2Key(from: ss)
|
||||||
|
return try XChaCha20Poly1305Compat.open(
|
||||||
// Components parsed
|
ciphertext: Data(ct),
|
||||||
|
tag: Data(tag),
|
||||||
// Derive shared secret - try with default Y coordinate first
|
key: key,
|
||||||
var sharedSecret: Data
|
nonce24: Data(nonce24)
|
||||||
var decrypted: Data? = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
sharedSecret = try deriveSharedSecret(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: senderPubkeyData
|
|
||||||
)
|
)
|
||||||
// Derived shared secret with first Y coordinate
|
}
|
||||||
|
|
||||||
// Try to decrypt
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
let sealedBox = try AES.GCM.SealedBox(
|
|
||||||
nonce: AES.GCM.Nonce(data: nonceData),
|
|
||||||
ciphertext: ciphertextData,
|
|
||||||
tag: tagData
|
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful
|
|
||||||
} catch {
|
|
||||||
// AES-GCM decryption failed, trying alternate
|
|
||||||
|
|
||||||
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
|
|
||||||
if senderPubkeyData.count == 32 {
|
if senderPubkeyData.count == 32 {
|
||||||
// Trying alternate Y coordinate
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
|
if let pt = try? attemptDecrypt(using: even) {
|
||||||
// Force deriveSharedSecret to use odd Y by manipulating the data
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
var altPubkey = Data()
|
}
|
||||||
altPubkey.append(0x03) // Force odd Y
|
let odd = Data([0x03]) + senderPubkeyData
|
||||||
altPubkey.append(senderPubkeyData)
|
let pt = try attemptDecrypt(using: odd)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
sharedSecret = try deriveSharedSecretDirect(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: altPubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful with alternate Y
|
|
||||||
} else {
|
} else {
|
||||||
throw error
|
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let finalDecrypted = decrypted else {
|
|
||||||
throw NostrError.encryptionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(data: finalDecrypted, encoding: .utf8) ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
privateKey: P256K.Schnorr.PrivateKey,
|
privateKey: P256K.Schnorr.PrivateKey,
|
||||||
@@ -412,17 +369,8 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
let result = derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
// Final derived key ready
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
// Direct version that doesn't try to add prefixes
|
||||||
@@ -452,15 +400,8 @@ struct NostrProtocol {
|
|||||||
// Convert SharedSecret to Data
|
// Convert SharedSecret to Data
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
@@ -473,7 +414,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")
|
||||||
|
|
||||||
@@ -529,16 +470,16 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
|
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
// Convert to Schnorr key for Nostr signing
|
// Sign with Schnorr (BIP-340)
|
||||||
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) // Zero auxiliary randomness for deterministic signing
|
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
|
|
||||||
@@ -581,3 +522,32 @@ enum NostrError: Error {
|
|||||||
case signingFailed
|
case signingFailed
|
||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||||
|
|
||||||
|
private extension NostrProtocol {
|
||||||
|
static func base64URLEncode(_ data: Data) -> String {
|
||||||
|
return data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
var str = s
|
||||||
|
let pad = (4 - (str.count % 4)) % 4
|
||||||
|
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||||
|
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
salt: Data(),
|
||||||
|
info: "nip44-v2".data(using: .utf8)!,
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject {
|
|||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
|
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
||||||
private let backoffMultiplier: Double = 2.0 // Double each time
|
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||||
private let maxReconnectAttempts = 10 // Stop after 10 attempts
|
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||||
|
|
||||||
// Reconnection timer
|
// Reconnection timer
|
||||||
private var reconnectionTimer: Timer?
|
private var reconnectionTimer: Timer?
|
||||||
@@ -289,7 +289,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Only log non-gift-wrap events to reduce noise
|
// Only log non-gift-wrap events to reduce noise
|
||||||
if event.kind != 1059 {
|
if event.kind != 1059 {
|
||||||
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
|
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
|
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
|
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
||||||
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let data = try encoder.encode(req)
|
let data = try encoder.encode(req)
|
||||||
let message = String(data: data, encoding: .utf8) ?? ""
|
let message = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)",
|
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
connection.send(.string(message)) { [weak self] error in
|
connection.send(.string(message)) { [weak self] error in
|
||||||
@@ -568,7 +568,7 @@ struct NostrFilter: Encodable {
|
|||||||
filter.kinds = [1059] // Gift wrap kind
|
filter.kinds = [1059] // Gift wrap kind
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.limit = 100 // Add a reasonable limit
|
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||||
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
|
/// as per XChaCha20 construction.
|
||||||
|
enum XChaCha20Poly1305Compat {
|
||||||
|
struct SealBox {
|
||||||
|
let ciphertext: Data
|
||||||
|
let tag: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
|
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||||
|
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
|
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||||
|
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||||
|
var out = Data(count: 12)
|
||||||
|
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||||
|
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
|
precondition(key.count == 32)
|
||||||
|
precondition(nonce16.count == 16)
|
||||||
|
|
||||||
|
// Constants "expand 32-byte k"
|
||||||
|
var state: [UInt32] = [
|
||||||
|
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||||
|
// key (8 words)
|
||||||
|
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||||
|
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||||
|
// nonce (4 words)
|
||||||
|
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||||
|
]
|
||||||
|
|
||||||
|
// 20 rounds (10 double rounds)
|
||||||
|
for _ in 0..<10 {
|
||||||
|
// Column rounds
|
||||||
|
quarterRound(&state, 0, 4, 8, 12)
|
||||||
|
quarterRound(&state, 1, 5, 9, 13)
|
||||||
|
quarterRound(&state, 2, 6, 10, 14)
|
||||||
|
quarterRound(&state, 3, 7, 11, 15)
|
||||||
|
// Diagonal rounds
|
||||||
|
quarterRound(&state, 0, 5, 10, 15)
|
||||||
|
quarterRound(&state, 1, 6, 11, 12)
|
||||||
|
quarterRound(&state, 2, 7, 8, 13)
|
||||||
|
quarterRound(&state, 3, 4, 9, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output subkey: state[0..3] and state[12..15]
|
||||||
|
var out = Data(count: 32)
|
||||||
|
out.storeLEWord(state[0], at: 0)
|
||||||
|
out.storeLEWord(state[1], at: 4)
|
||||||
|
out.storeLEWord(state[2], at: 8)
|
||||||
|
out.storeLEWord(state[3], at: 12)
|
||||||
|
out.storeLEWord(state[12], at: 16)
|
||||||
|
out.storeLEWord(state[13], at: 20)
|
||||||
|
out.storeLEWord(state[14], at: 24)
|
||||||
|
out.storeLEWord(state[15], at: 28)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||||
|
let range = offset..<(offset+4)
|
||||||
|
let bytes = self[range]
|
||||||
|
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||||
|
let b = ptr.bindMemory(to: UInt8.self)
|
||||||
|
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||||
|
let bytes: [UInt8] = [
|
||||||
|
UInt8(value & 0xff),
|
||||||
|
UInt8((value >> 8) & 0xff),
|
||||||
|
UInt8((value >> 16) & 0xff),
|
||||||
|
UInt8((value >> 24) & 0xff)
|
||||||
|
]
|
||||||
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
case delivered = 0x03 // Message was delivered
|
||||||
|
// Verification (QR-based OOB binding)
|
||||||
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
|
case .verifyResponse: return "verifyResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,8 +181,7 @@ 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
|
||||||
|
|
||||||
@@ -263,8 +267,7 @@ 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
|
||||||
|
|
||||||
@@ -356,7 +359,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", "/help",
|
"/msg", "/who", "/clear",
|
||||||
"/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", "/help":
|
case "/who", "/clear":
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|||||||
+703
-166
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,15 @@ 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)
|
||||||
@@ -49,11 +58,14 @@ 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 handleHelp()
|
return .error(message: "unknown command: \(cmd)")
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
@@ -85,19 +97,34 @@ 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?.messages.removeAll()
|
chatViewModel?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import CoreLocation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
/// 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.
|
||||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||||
@@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
super.init()
|
super.init()
|
||||||
cl.delegate = self
|
cl.delegate = self
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
cl.distanceFilter = 1000 // meters; we're not tracking continuously
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||||
// Load selection
|
// Load selection
|
||||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
@@ -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, *) {
|
if #available(iOS 14.0, macOS 11.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, *) {
|
if #available(iOS 14.0, macOS 11.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:
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
Task { @MainActor in self.permissionState = .authorized }
|
Task { @MainActor in self.permissionState = .authorized }
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -92,23 +92,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||||
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||||
|
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||||
guard permissionState == .authorized else { return }
|
guard permissionState == .authorized else { return }
|
||||||
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
// Stop any previous polling timer
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
refreshTimer = nil
|
||||||
self?.requestOneShotLocation()
|
// Tighten accuracy and distance filter for live view
|
||||||
}
|
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||||
// Kick off immediately
|
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||||
|
// Start continuous updates
|
||||||
|
cl.startUpdatingLocation()
|
||||||
|
// Request an immediate fix to populate UI without waiting for movement
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop periodic refreshes when selector UI is dismissed.
|
/// Stop continuous refreshes when selector UI is dismissed.
|
||||||
func endLiveRefresh() {
|
func endLiveRefresh() {
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = nil
|
||||||
cl.stopUpdatingLocation()
|
cl.stopUpdatingLocation()
|
||||||
|
// Restore more relaxed defaults for background/idle state
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(_ channel: ChannelID) {
|
func select(_ channel: ChannelID) {
|
||||||
@@ -151,8 +158,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 14+
|
// iOS 14+ / macOS 11+
|
||||||
@available(iOS 14.0, *)
|
@available(iOS 14.0, macOS 11.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 +187,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: newState = .authorized
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||||
@unknown default: newState = .restricted
|
@unknown default: newState = .restricted
|
||||||
}
|
}
|
||||||
Task { @MainActor in self.permissionState = newState }
|
Task { @MainActor in self.permissionState = newState }
|
||||||
@@ -256,5 +263,4 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
let hasMesh = mesh.isPeerConnected(peerID)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
if reachableMesh {
|
||||||
if hasMesh && hasEstablished {
|
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
@@ -57,9 +57,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -70,7 +70,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
|
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
@@ -101,7 +103,7 @@ final class MessageRouter {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = 0.35 // ~3 per second
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
var myPeerID: String { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
@@ -31,6 +31,7 @@ final class NostrTransport: Transport {
|
|||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||||
|
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: String) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [String : String] { [:] }
|
||||||
|
|
||||||
@@ -48,16 +49,7 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Resolve favorite by full noise key or by short peerID fallback
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
var recipientNostrPubkey: String?
|
|
||||||
if let noiseKey = Data(hexString: peerID),
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
|
||||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
|
||||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
@@ -105,15 +97,7 @@ final class NostrTransport: Transport {
|
|||||||
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
var recipientNostrPubkey: String?
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
if let noiseKey = Data(hexString: item.peerID),
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
|
||||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
if recipientNostrPubkey == nil, item.peerID.count == 16 {
|
|
||||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
@@ -149,15 +133,7 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
var recipientNostrPubkey: String?
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
if let noiseKey = Data(hexString: peerID),
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
|
||||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
|
||||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
||||||
@@ -183,18 +159,26 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
@MainActor
|
||||||
|
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||||
|
if let noiseKey = Data(hexString: peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
let npub = fav.peerNostrPublicKey {
|
||||||
|
return npub
|
||||||
|
}
|
||||||
|
if peerID.count == 16,
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
|
let npub = fav.peerNostrPublicKey {
|
||||||
|
return npub
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
var recipientNostrPubkey: String?
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
if let noiseKey = Data(hexString: peerID),
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
|
||||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
|
||||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
|
let title = "\(titlePrefix)\(geohash)"
|
||||||
|
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
||||||
|
let deeplink = "bitchat://geohash/\(geohash)"
|
||||||
|
let userInfo: [String: Any] = ["deeplink": deeplink]
|
||||||
|
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = 1337
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: String) {
|
||||||
|
|||||||
@@ -12,14 +12,27 @@ 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,
|
||||||
|
isAnnounce: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
// 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) }
|
||||||
|
|
||||||
// Degree-aware probability to reduce floods in dense graphs
|
// For session-critical or directed traffic, be deterministic and reliable
|
||||||
|
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
|
||||||
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
|
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||||
|
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
|
||||||
@@ -28,20 +41,29 @@ struct RelayController {
|
|||||||
case 7...9: baseProb = 0.55
|
case 7...9: baseProb = 0.55
|
||||||
default: baseProb = 0.45
|
default: baseProb = 0.45
|
||||||
}
|
}
|
||||||
var prob = baseProb
|
let 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
|
// TTL clamping for broadcast
|
||||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
// - Dense graphs: keep very low to avoid floods
|
||||||
|
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
||||||
|
// - Announces in sparse graphs get a bit more headroom
|
||||||
|
let ttlCap: UInt8 = {
|
||||||
|
if degree >= highDegreeThreshold { return 3 }
|
||||||
|
return isAnnounce ? 7 : 6
|
||||||
|
}()
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let clamped = max(1, min(ttl, ttlCap))
|
||||||
let newTTL = clamped &- 1
|
let newTTL = clamped &- 1
|
||||||
|
|
||||||
// Short jitter to desynchronize rebroadcasts
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
let delayMs = Int.random(in: 20...80)
|
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||||
|
let delayMs: Int
|
||||||
|
switch degree {
|
||||||
|
case 0...2: delayMs = Int.random(in: 10...40)
|
||||||
|
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 {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
@@ -29,6 +29,7 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: String) -> Bool
|
||||||
|
func isPeerReachable(_ peerID: String) -> Bool
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: String) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [String: String]
|
||||||
|
|
||||||
@@ -46,11 +47,20 @@ protocol Transport: AnyObject {
|
|||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||||
|
|
||||||
|
// QR verification (optional for transports)
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
// Peer snapshots (for non-UI services)
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Transport {
|
||||||
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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 = 5.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
|
||||||
|
// How long without seeing traffic before we sanity-check the direct link
|
||||||
|
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
||||||
|
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
||||||
|
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
||||||
|
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
||||||
|
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
||||||
|
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
|
||||||
|
// Keep scanning fully ON when we saw traffic very recently
|
||||||
|
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||||
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
|
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||||
|
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||||
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
|
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
|
||||||
|
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
|
||||||
|
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
|
||||||
|
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
|
||||||
|
|
||||||
|
// Location
|
||||||
|
static let locationDistanceFilterMeters: Double = 1000
|
||||||
|
// Live (channel sheet open) distance threshold for meaningful updates
|
||||||
|
static let locationDistanceFilterLiveMeters: Double = 21.0
|
||||||
|
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||||
|
|
||||||
|
// Notifications (geohash)
|
||||||
|
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
|
||||||
|
static let uiGeoNotifySnippetMaxLen: Int = 80
|
||||||
|
|
||||||
|
// 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 = 0.6
|
||||||
|
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
|
||||||
|
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
|
||||||
|
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
|
||||||
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
|
// Store-and-forward for directed packets at relays
|
||||||
|
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||||
|
|
||||||
|
// Log/UI debounce windows
|
||||||
|
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
||||||
|
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
||||||
|
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
||||||
|
|
||||||
|
// Weak-link cooldown after connection timeouts
|
||||||
|
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||||
|
static let bleWeakLinkRSSICutoff: Int = -90
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Peer list palette
|
||||||
|
static let uiPeerPaletteSlots: Int = 36
|
||||||
|
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
|
||||||
|
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -68,24 +68,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func updatePeers() {
|
private func updatePeers() {
|
||||||
let meshPeers = meshService.currentPeerSnapshots()
|
let meshPeers = meshService.currentPeerSnapshots()
|
||||||
|
// If we have no direct links at all, peers should not be marked reachable
|
||||||
|
// "Reachable" means mesh-attached via at least one live link.
|
||||||
|
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<String> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<String> = []
|
||||||
|
|
||||||
// Phase 1: Add all connected mesh peers
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers where peerInfo.isConnected {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.id
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
peerInfo: peerInfo,
|
peerInfo: peerInfo,
|
||||||
favorites: favorites
|
favorites: favorites,
|
||||||
|
meshAttached: hasAnyConnected
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
connected.insert(peerID)
|
if peer.isConnected { connected.insert(peerID) }
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
@@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 3: Sort peers
|
// Phase 3: Sort peers
|
||||||
enrichedPeers.sort { lhs, rhs in
|
enrichedPeers.sort { lhs, rhs in
|
||||||
// Connected first
|
// Connectivity rank: connected > reachable > others
|
||||||
if lhs.isConnected != rhs.isConnected {
|
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
||||||
return lhs.isConnected
|
let lr = rank(lhs), rr = rank(rhs)
|
||||||
}
|
if lr != rr { return lr > rr }
|
||||||
// Then favorites
|
// Then favorites inside same rank
|
||||||
if lhs.isFavorite != rhs.isFavorite {
|
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
||||||
return lhs.isFavorite
|
|
||||||
}
|
|
||||||
// Finally alphabetical
|
// Finally alphabetical
|
||||||
return lhs.displayName < rhs.displayName
|
return lhs.displayName < rhs.displayName
|
||||||
}
|
}
|
||||||
@@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Update published properties
|
// Phase 5: Filter out offline non-mutual peers and update published properties
|
||||||
self.peers = enrichedPeers
|
let filtered = enrichedPeers.filter { p in
|
||||||
|
p.isConnected || p.isReachable || p.isMutualFavorite
|
||||||
|
}
|
||||||
|
self.peers = filtered
|
||||||
self.connectedPeerIDs = connected
|
self.connectedPeerIDs = connected
|
||||||
self.favorites = favoritesList
|
self.favorites = favoritesList
|
||||||
self.mutualFavorites = mutualsList
|
self.mutualFavorites = mutualsList
|
||||||
@@ -162,14 +167,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromMesh(
|
private func buildPeerFromMesh(
|
||||||
peerInfo: TransportPeerSnapshot,
|
peerInfo: TransportPeerSnapshot,
|
||||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||||
|
meshAttached: Bool
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
|
// Determine reachability based on lastSeen and identity trust
|
||||||
|
let now = Date()
|
||||||
|
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
||||||
|
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
||||||
|
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
||||||
|
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
||||||
|
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
||||||
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
id: peerInfo.id,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
isConnected: true
|
isConnected: peerInfo.isConnected,
|
||||||
|
isReachable: isReachable
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for favorite status
|
// Check for favorite status
|
||||||
@@ -216,7 +233,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
isConnected: false
|
isConnected: false,
|
||||||
|
isReachable: false
|
||||||
)
|
)
|
||||||
|
|
||||||
peer.favoriteStatus = favorite
|
peer.favoriteStatus = favorite
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
|
final class VerificationService {
|
||||||
|
static let shared = VerificationService()
|
||||||
|
|
||||||
|
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||||
|
private var noise: NoiseEncryptionService?
|
||||||
|
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||||
|
|
||||||
|
/// Encapsulates the data encoded into a verification QR
|
||||||
|
struct VerificationQR: Codable {
|
||||||
|
let v: Int
|
||||||
|
let noiseKeyHex: String
|
||||||
|
let signKeyHex: String
|
||||||
|
let npub: String?
|
||||||
|
let nickname: String
|
||||||
|
let ts: Int64
|
||||||
|
let nonceB64: String
|
||||||
|
var sigHex: String
|
||||||
|
|
||||||
|
static let context = "bitchat-verify-v1"
|
||||||
|
|
||||||
|
/// Canonical bytes used for signature (deterministic ordering)
|
||||||
|
func canonicalBytes() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
func appendField(_ s: String) {
|
||||||
|
let d = s.data(using: .utf8) ?? Data()
|
||||||
|
out.append(UInt8(min(d.count, 255)))
|
||||||
|
out.append(d.prefix(255))
|
||||||
|
}
|
||||||
|
appendField(Self.context)
|
||||||
|
appendField(String(v))
|
||||||
|
appendField(noiseKeyHex.lowercased())
|
||||||
|
appendField(signKeyHex.lowercased())
|
||||||
|
appendField(npub ?? "")
|
||||||
|
appendField(nickname)
|
||||||
|
appendField(String(ts))
|
||||||
|
appendField(nonceB64)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toURLString() -> String {
|
||||||
|
var comps = URLComponents()
|
||||||
|
comps.scheme = "bitchat"
|
||||||
|
comps.host = "verify"
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "v", value: String(v)),
|
||||||
|
URLQueryItem(name: "noise", value: noiseKeyHex),
|
||||||
|
URLQueryItem(name: "sign", value: signKeyHex),
|
||||||
|
URLQueryItem(name: "nick", value: nickname),
|
||||||
|
URLQueryItem(name: "ts", value: String(ts)),
|
||||||
|
URLQueryItem(name: "nonce", value: nonceB64),
|
||||||
|
URLQueryItem(name: "sig", value: sigHex)
|
||||||
|
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
|
||||||
|
return comps.string ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromURL(_ url: URL) -> VerificationQR? {
|
||||||
|
guard url.scheme == "bitchat", url.host == "verify",
|
||||||
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
|
||||||
|
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
|
||||||
|
guard let vStr = val("v"), let v = Int(vStr),
|
||||||
|
let noise = val("noise"), let sign = val("sign"),
|
||||||
|
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
|
||||||
|
let nonce = val("nonce"), let sig = val("sig") else { return nil }
|
||||||
|
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Build a signed QR string for the current identity
|
||||||
|
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
||||||
|
// Simple short-lived cache to speed up sheet opening
|
||||||
|
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
|
||||||
|
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
|
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||||
|
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||||
|
let ts = Int64(Date().timeIntervalSince1970)
|
||||||
|
var nonce = Data(count: 16)
|
||||||
|
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
|
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||||
|
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||||
|
let msg = payload.canonicalBytes()
|
||||||
|
guard let sig = noise.signData(msg) else { return nil }
|
||||||
|
let signed = VerificationQR(v: payload.v,
|
||||||
|
noiseKeyHex: payload.noiseKeyHex,
|
||||||
|
signKeyHex: payload.signKeyHex,
|
||||||
|
npub: payload.npub,
|
||||||
|
nickname: payload.nickname,
|
||||||
|
ts: payload.ts,
|
||||||
|
nonceB64: payload.nonceB64,
|
||||||
|
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||||
|
let out = signed.toURLString()
|
||||||
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||||
|
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
||||||
|
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||||
|
// Freshness
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
if now - Double(qr.ts) > maxAge { return nil }
|
||||||
|
// Verify signature using embedded ed25519 signKey
|
||||||
|
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||||
|
guard let noise = noise else { return nil }
|
||||||
|
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||||
|
return ok ? qr : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise payloads (scaffold only)
|
||||||
|
|
||||||
|
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
|
||||||
|
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
|
||||||
|
var tlv = Data()
|
||||||
|
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
|
||||||
|
tlv.append(contentsOf: n0)
|
||||||
|
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
|
||||||
|
tlv.append(0x02)
|
||||||
|
tlv.append(UInt8(min(nonceA.count, 255)))
|
||||||
|
tlv.append(nonceA.prefix(255))
|
||||||
|
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
|
||||||
|
// Sign context: verify-response | noiseKeyHex | nonceA
|
||||||
|
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||||
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
|
msg.append(nonceA)
|
||||||
|
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||||
|
var tlv = Data()
|
||||||
|
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||||
|
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||||
|
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
||||||
|
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
// Expect type already stripped; we receive only TLV here
|
||||||
|
// TLV 0x01 noiseKeyHex
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
// TLV 0x02 nonceA
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
return (noiseStr, nA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
|
||||||
|
var idx = 0
|
||||||
|
func take(_ n: Int) -> Data? {
|
||||||
|
guard idx + n <= data.count else { return nil }
|
||||||
|
let d = data[idx..<(idx+n)]
|
||||||
|
idx += n
|
||||||
|
return Data(d)
|
||||||
|
}
|
||||||
|
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||||
|
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||||
|
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||||
|
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
|
||||||
|
return (noiseStr, nA, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
|
||||||
|
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||||
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
|
msg.append(nonceA)
|
||||||
|
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||||
|
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import Compression
|
|||||||
|
|
||||||
struct CompressionUtil {
|
struct CompressionUtil {
|
||||||
// Compression threshold - don't compress if data is smaller than this
|
// Compression threshold - don't compress if data is smaller than this
|
||||||
static let compressionThreshold = 100 // bytes
|
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
||||||
|
|
||||||
// Compress data using zlib algorithm (most compatible)
|
// Compress data using zlib algorithm (most compatible)
|
||||||
static func compress(_ data: Data) -> Data? {
|
static func compress(_ data: Data) -> Data? {
|
||||||
|
|||||||
@@ -18,14 +18,18 @@ 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 (16-hex)
|
// Accept short routing IDs (exact 16-hex)
|
||||||
if PeerIDResolver.isShortID(peerID) { return true }
|
if PeerIDResolver.isShortID(peerID) { return true }
|
||||||
// Accept full Noise key hex (64-hex)
|
// If length equals short-hex length but isn't valid hex, reject
|
||||||
|
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 }
|
||||||
// Internal format: alphanumeric + dash/underscore up to 64
|
// If length equals full key length but isn't valid hex, reject
|
||||||
|
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 = 300 // 5 minutes
|
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||||
private let maxCount = 1000
|
private let maxCount = TransportConfig.messageDedupMaxCount
|
||||||
|
|
||||||
/// Check if message is duplicate and add if not
|
/// Check if message is duplicate and add if not
|
||||||
func isDuplicate(_ messageID: String) -> Bool {
|
func isDuplicate(_ messageID: String) -> Bool {
|
||||||
@@ -84,4 +84,3 @@ final class MessageDeduplicator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
|
||||||
|
struct PeerDisplayNameResolver {
|
||||||
|
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - peers: Array of tuples (id, nickname, isConnected).
|
||||||
|
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||||
|
/// - Returns: Map of peerID -> displayName.
|
||||||
|
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
||||||
|
// Count collisions among connected peers and include our own nickname
|
||||||
|
var counts: [String: Int] = [:]
|
||||||
|
for p in peers where p.isConnected {
|
||||||
|
counts[p.nickname, default: 0] += 1
|
||||||
|
}
|
||||||
|
counts[selfNickname, default: 0] += 1
|
||||||
|
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for p in peers {
|
||||||
|
var name = p.nickname
|
||||||
|
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||||
|
name += "#" + String(p.id.prefix(4))
|
||||||
|
}
|
||||||
|
result[p.id] = name
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
+116
-169
@@ -13,19 +13,9 @@ 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
|
||||||
|
|
||||||
@@ -33,9 +23,7 @@ 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
|
||||||
@@ -58,6 +46,7 @@ struct ContentView: View {
|
|||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTimer: Timer?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
|
@State private var showVerifySheet = false
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -111,14 +100,14 @@ struct ContentView: View {
|
|||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
if translation > 50 || (translation > 30 && velocity > 300) {
|
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.easeOut(duration: 0.15)) {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +121,7 @@ struct ContentView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -162,14 +151,14 @@ struct ContentView: View {
|
|||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
||||||
return showSidebar ? -dragOffset : width - dragOffset
|
return showSidebar ? -dragOffset : width - dragOffset
|
||||||
}())
|
}())
|
||||||
.animation(.easeInOut(duration: 0.25), value: showSidebar)
|
.animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showPrivateChat = newValue != nil
|
showPrivateChat = newValue != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +188,6 @@ 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)
|
||||||
@@ -207,10 +195,7 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
#else
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
viewModel.startPrivateChat(with: peerID)
|
|
||||||
#endif
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -231,7 +216,6 @@ 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 {
|
||||||
@@ -239,9 +223,6 @@ 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) {}
|
||||||
@@ -283,22 +264,19 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Implement windowing with adjustable window count per chat
|
// Implement windowing with adjustable window count per chat
|
||||||
let currentWindowCount: Int = {
|
let currentWindowCount: Int = {
|
||||||
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
|
if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate }
|
||||||
return windowCountPublic
|
return windowCountPublic
|
||||||
}()
|
}()
|
||||||
let windowedMessages = messages.suffix(currentWindowCount)
|
let windowedMessages = messages.suffix(currentWindowCount)
|
||||||
|
|
||||||
// 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)" }
|
||||||
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 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
|
||||||
@@ -318,11 +296,11 @@ struct ContentView: View {
|
|||||||
let cashuTokens = message.content.extractCashuTokens()
|
let cashuTokens = message.content.extractCashuTokens()
|
||||||
let lightningLinks = message.content.extractLightningLinks()
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(isLong && !isExpanded ? 30 : nil)
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
@@ -334,7 +312,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
|
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Button(isExpanded ? "show less" : "show more") {
|
Button(isExpanded ? "show less" : "show more") {
|
||||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
@@ -393,20 +371,17 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
||||||
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
||||||
let step = 200
|
let step = TransportConfig.uiWindowStepCount
|
||||||
#if os(iOS)
|
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
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 preserveID = "\(contextKey)|\(message.id)"
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
let current = windowCountPrivate[peer] ?? 300
|
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||||
let newCount = min(messages.count, current + step)
|
let newCount = min(messages.count, current + step)
|
||||||
if newCount != current {
|
if newCount != current {
|
||||||
windowCountPrivate[peer] = newCount
|
windowCountPrivate[peer] = newCount
|
||||||
@@ -472,7 +447,6 @@ 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
|
||||||
@@ -487,7 +461,6 @@ 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
|
||||||
@@ -500,16 +473,12 @@ 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
|
||||||
}()
|
}()
|
||||||
@@ -524,16 +493,12 @@ 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
|
||||||
}()
|
}()
|
||||||
@@ -547,16 +512,12 @@ 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
|
||||||
}()
|
}()
|
||||||
@@ -579,19 +540,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Throttle scroll animations to prevent excessive UI updates
|
// Throttle scroll animations to prevent excessive UI updates
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||||
// Immediate scroll if enough time has passed
|
// Immediate scroll if enough time has passed
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
#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 {
|
||||||
@@ -600,18 +557,14 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Schedule a delayed scroll
|
// Schedule a delayed scroll
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
#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 {
|
||||||
@@ -636,7 +589,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Same throttling for private chats
|
// Same throttling for private chats
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey = "dm:\(peerID)"
|
let contextKey = "dm:\(peerID)"
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
@@ -646,7 +599,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
let contextKey = "dm:\(peerID)"
|
let contextKey = "dm:\(peerID)"
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
@@ -658,7 +611,6 @@ 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 }
|
||||||
@@ -667,7 +619,7 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Reset window size
|
// Reset window size
|
||||||
windowCountPublic = 300
|
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
let contextKey = "geo:\(ch.geohash)"
|
||||||
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
||||||
let target = last.map { "\(contextKey)|\($0)" }
|
let target = last.map { "\(contextKey)|\($0)" }
|
||||||
@@ -677,18 +629,17 @@ 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() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,18 +698,22 @@ 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 commandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
let baseInfo: [(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
|
||||||
@@ -833,18 +788,25 @@ 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 commandDescriptions = [
|
let isGeoPublic: Bool = {
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -897,7 +859,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Delay keyboard focus to avoid iOS constraint warnings
|
// Delay keyboard focus to avoid iOS constraint warnings
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -926,6 +888,15 @@ 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
|
||||||
|
if case .mesh = locationManager.selectedChannel {
|
||||||
|
Button(action: { showVerifySheet = true }) {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Verification: show my QR or scan a friend")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: 44) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -938,7 +909,6 @@ 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,
|
||||||
@@ -955,7 +925,7 @@ struct ContentView: View {
|
|||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -967,24 +937,6 @@ 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())
|
||||||
@@ -1021,7 +973,7 @@ struct ContentView: View {
|
|||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
if !showSidebar {
|
if !showSidebar {
|
||||||
if translation < -100 || (translation < -50 && velocity < -500) {
|
if translation < -100 || (translation < -50 && velocity < -500) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -1074,29 +1026,24 @@ struct ContentView: View {
|
|||||||
return (name, "")
|
return (name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
// Compute channel-aware people count and color for toolbar (cross-platform)
|
||||||
// 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:
|
||||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||||
let isMeshConnected = peer.isConnected
|
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
else if peer.isReachable { 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 {
|
||||||
@@ -1143,7 +1090,6 @@ 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 = {
|
||||||
@@ -1152,24 +1098,11 @@ 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
|
// Unread indicator (now shown on iOS and macOS)
|
||||||
#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")
|
||||||
@@ -1204,7 +1137,6 @@ 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
|
||||||
@@ -1216,21 +1148,25 @@ struct ContentView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
|
|
||||||
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar.toggle()
|
showSidebar.toggle()
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showVerifySheet) {
|
||||||
|
VerificationSheetView(isPresented: $showVerifySheet)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,20 +1180,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||||
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
||||||
let headerPeerID: String = {
|
let headerPeerID: String = {
|
||||||
if privatePeerID.count == 16 {
|
if privatePeerID.count == 64 {
|
||||||
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
|
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
||||||
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
||||||
return stable
|
|
||||||
}
|
|
||||||
} else if privatePeerID.count == 64 {
|
|
||||||
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
|
|
||||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
|
||||||
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return privatePeerID
|
return privatePeerID
|
||||||
}()
|
}()
|
||||||
@@ -1266,18 +1193,35 @@ 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 ??
|
// Try mesh/unified peer display
|
||||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
if let name = peer?.displayName { return name }
|
||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
// Try direct mesh nickname (connected-only)
|
||||||
"Unknown"
|
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||||
|
// Try favorite nickname by stable Noise key
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||||
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
|
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||||
|
if headerPeerID.count == 16 {
|
||||||
|
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
|
if let id = candidates.first,
|
||||||
|
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
|
}
|
||||||
|
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||||
|
let fp = keyData.sha256Fingerprint()
|
||||||
|
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
}()
|
}()
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
@@ -1312,6 +1256,12 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
|
case .meshReachable:
|
||||||
|
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||||
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel("Reachable via mesh")
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1322,6 +1272,12 @@ struct ContentView: View {
|
|||||||
// Should not happen for PM header, but handle gracefully
|
// Should not happen for PM header, but handle gracefully
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
|
// Fallback: reachable via mesh but not in current peer list
|
||||||
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityLabel("Reachable via mesh")
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1340,7 +1296,14 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||||
|
let statusPeerID: String = {
|
||||||
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
return headerPeerID
|
||||||
|
}()
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
@@ -1359,7 +1322,7 @@ struct ContentView: View {
|
|||||||
// Left and right buttons positioned with HStack
|
// Left and right buttons positioned with HStack
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
@@ -1437,23 +1400,7 @@ 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 {
|
||||||
|
|||||||
@@ -32,17 +32,36 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("DONE") {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Peer info
|
// Prefer short mesh ID for session/encryption status
|
||||||
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
let statusPeerID: String = {
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||||
|
return peerID
|
||||||
|
}()
|
||||||
|
// Resolve a friendly name
|
||||||
|
let peerNickname: String = {
|
||||||
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
|
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||||
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
|
let fp = data.sha256Fingerprint()
|
||||||
|
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}()
|
||||||
|
// Accurate encryption state based on short ID session
|
||||||
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -73,7 +92,7 @@ struct FingerprintView: View {
|
|||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -165,6 +184,20 @@ struct FingerprintView: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("REMOVE VERIFICATION")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.red)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -29,16 +28,12 @@ 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) }
|
||||||
@@ -52,11 +47,7 @@ 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
|
||||||
@@ -127,10 +118,8 @@ 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))
|
||||||
@@ -142,4 +131,3 @@ private func splitSuffix(from name: String) -> (String, String) {
|
|||||||
}
|
}
|
||||||
return (name, "")
|
return (name, "")
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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
|
||||||
@@ -37,11 +40,7 @@ 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") {
|
Button("open settings") { openSystemLocationSettings() }
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -54,6 +53,7 @@ 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,8 +61,21 @@ 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 {
|
||||||
@@ -70,24 +83,17 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
// Begin periodic refresh while sheet is open
|
// Begin periodic refresh while sheet is open
|
||||||
manager.beginLiveRefresh()
|
manager.beginLiveRefresh()
|
||||||
// Begin multi-channel sampling for counts
|
// Geohash sampling is now managed by ChatViewModel globally
|
||||||
let ghs = manager.availableChannels.map { $0.geohash }
|
|
||||||
viewModel.beginGeohashSampling(for: ghs)
|
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
manager.endLiveRefresh()
|
manager.endLiveRefresh()
|
||||||
viewModel.endGeohashSampling()
|
|
||||||
}
|
}
|
||||||
.onChange(of: manager.permissionState) { newValue in
|
.onChange(of: manager.permissionState) { newValue in
|
||||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||||
manager.refreshChannels()
|
manager.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: manager.availableChannels) { newValue in
|
.onChange(of: manager.availableChannels) { _ in }
|
||||||
// Keep sampling list in sync with available channels as they refresh live
|
|
||||||
let ghs = newValue.map { $0.geohash }
|
|
||||||
viewModel.beginGeohashSampling(for: ghs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
@@ -128,10 +134,12 @@ 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)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.keyboardType(.asciiCapable)
|
.keyboardType(.asciiCapable)
|
||||||
|
#endif
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.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")
|
||||||
@@ -183,9 +191,7 @@ 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: {
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
openSystemLocationSettings()
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Text("remove location access")
|
Text("remove location access")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
@@ -276,9 +282,10 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func meshCount() -> Int {
|
private func meshCount() -> Int {
|
||||||
|
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +350,7 @@ extension LocationChannelsSheet {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
let usesMetric: Bool = {
|
let usesMetric: Bool = {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, macOS 13.0, *) {
|
||||||
return Locale.current.measurementSystem == .metric
|
return Locale.current.measurementSystem == .metric
|
||||||
} else {
|
} else {
|
||||||
return Locale.current.usesMetricSystem
|
return Locale.current.usesMetricSystem
|
||||||
@@ -366,7 +373,7 @@ extension LocationChannelsSheet {
|
|||||||
|
|
||||||
private func bluetoothRangeString() -> String {
|
private func bluetoothRangeString() -> String {
|
||||||
let usesMetric: Bool = {
|
let usesMetric: Bool = {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, macOS 13.0, *) {
|
||||||
return Locale.current.measurementSystem == .metric
|
return Locale.current.measurementSystem == .metric
|
||||||
} else {
|
} else {
|
||||||
return Locale.current.usesMetricSystem
|
return Locale.current.usesMetricSystem
|
||||||
@@ -390,4 +397,17 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,9 +44,29 @@ struct MeshPeerList: View {
|
|||||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if peer.isConnected {
|
||||||
|
// Mesh-connected peer: radio icon
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if peer.isReachable {
|
||||||
|
// Mesh-reachable (relayed): point.3 icon
|
||||||
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if peer.isMutualFavorite {
|
||||||
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.purple)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
|
// Fallback icon for others (dimmed)
|
||||||
|
Image(systemName: "person")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
@@ -70,14 +90,39 @@ struct MeshPeerList: View {
|
|||||||
.help("Blocked")
|
.help("Blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let icon = item.enc.icon, !isMe {
|
if !isMe {
|
||||||
|
if peer.isConnected {
|
||||||
|
if let icon = item.enc.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
|
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
} else if let icon = item.enc.icon {
|
||||||
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(baseColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Unread message indicator for this peer
|
||||||
|
if !isMe, item.hasUnread {
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.help("New messages")
|
||||||
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CoreImage
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Placeholder view to display the user's verification QR payload as text.
|
||||||
|
struct MyQRView: View {
|
||||||
|
let qrString: String
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("scan to verify me")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
QRCodeImage(data: qrString, size: 240)
|
||||||
|
.accessibilityLabel("verification qr code")
|
||||||
|
|
||||||
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
|
Text(qrString)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(8)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a QR code image for a given string using CoreImage
|
||||||
|
struct QRCodeImage: View {
|
||||||
|
let data: String
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
private let context = CIContext()
|
||||||
|
private let filter = CIFilter.qrCodeGenerator()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image = generateImage() {
|
||||||
|
ImageWrapper(image: image)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.overlay(
|
||||||
|
Text("qr unavailable")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateImage() -> CGImage? {
|
||||||
|
let inputData = Data(data.utf8)
|
||||||
|
filter.message = inputData
|
||||||
|
filter.correctionLevel = "M"
|
||||||
|
guard let outputImage = filter.outputImage else { return nil }
|
||||||
|
let scale = max(1, Int(size / 32))
|
||||||
|
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
||||||
|
return context.createCGImage(transformed, from: transformed.extent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific wrapper to display CGImage in SwiftUI
|
||||||
|
struct ImageWrapper: View {
|
||||||
|
let image: CGImage
|
||||||
|
var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
|
let ui = UIImage(cgImage: image)
|
||||||
|
return Image(uiImage: ui)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
#else
|
||||||
|
let ns = NSImage(cgImage: image, size: .zero)
|
||||||
|
return Image(nsImage: ns)
|
||||||
|
.interpolation(.none)
|
||||||
|
.resizable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||||
|
struct QRScanView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
var isActive: Bool = true
|
||||||
|
@State private var input = ""
|
||||||
|
@State private var result: String = "" // not shown for iOS scanner
|
||||||
|
@State private var lastValid: String = ""
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
#if os(iOS)
|
||||||
|
CameraScannerView(isActive: isActive) { code in
|
||||||
|
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||||
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
if !ok { /* already pending; continue scanning */ }
|
||||||
|
lastValid = code
|
||||||
|
} else {
|
||||||
|
// ignore invalid reads; continue scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 260)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
#else
|
||||||
|
Text("paste qr content to validate:")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||||
|
TextEditor(text: $input)
|
||||||
|
.frame(height: 100)
|
||||||
|
.border(Color.gray.opacity(0.4))
|
||||||
|
Button("validate") {
|
||||||
|
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||||
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
|
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||||
|
} else {
|
||||||
|
result = "invalid or expired qr payload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
#endif
|
||||||
|
// No status text under camera per design
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct CameraScannerView: UIViewRepresentable {
|
||||||
|
typealias UIViewType = PreviewView
|
||||||
|
var isActive: Bool
|
||||||
|
var onCode: (String) -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> PreviewView {
|
||||||
|
let view = PreviewView()
|
||||||
|
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||||
|
context.coordinator.setActive(isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
private var onCode: ((String) -> Void)?
|
||||||
|
private weak var owner: PreviewView?
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private var isRunning = false
|
||||||
|
private var permissionGranted = false
|
||||||
|
private var desiredActive = false
|
||||||
|
|
||||||
|
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||||
|
self.owner = sessionOwner
|
||||||
|
self.onCode = onCode
|
||||||
|
session.beginConfiguration()
|
||||||
|
session.sessionPreset = .high
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
session.canAddInput(input) else { return }
|
||||||
|
session.addInput(input)
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(output) else { return }
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
|
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
}
|
||||||
|
session.commitConfiguration()
|
||||||
|
sessionOwner.videoPreviewLayer.session = session
|
||||||
|
// Request permission and start
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
|
self.permissionGranted = granted
|
||||||
|
if granted && self.desiredActive && !self.isRunning {
|
||||||
|
self.setActive(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActive(_ active: Bool) {
|
||||||
|
desiredActive = active
|
||||||
|
guard permissionGranted else { return }
|
||||||
|
if active && !isRunning {
|
||||||
|
isRunning = true
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if !self.session.isRunning { self.session.startRunning() }
|
||||||
|
}
|
||||||
|
} else if !active && isRunning {
|
||||||
|
isRunning = false
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
if self.session.isRunning { self.session.stopRunning() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||||
|
for obj in metadataObjects {
|
||||||
|
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||||
|
m.type == .qr,
|
||||||
|
let str = m.stringValue else { continue }
|
||||||
|
onCode?(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PreviewView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||||
|
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
videoPreviewLayer.videoGravity = .resizeAspectFill
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Combined sheet: shows my QR by default with a button to scan instead
|
||||||
|
struct VerificationSheetView: View {
|
||||||
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var showingScanner = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
|
||||||
|
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
private func myQRString() -> String {
|
||||||
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Top header (always at top)
|
||||||
|
HStack {
|
||||||
|
Text("VERIFY")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
showingScanner = false
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Content area
|
||||||
|
Group {
|
||||||
|
if showingScanner {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("scan a friend's qr")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(accentColor)
|
||||||
|
#if os(iOS)
|
||||||
|
QRScanView(isActive: showingScanner)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.frame(height: 280)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
#else
|
||||||
|
QRScanView()
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(boxColor)
|
||||||
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
let qr = myQRString()
|
||||||
|
MyQRView(qrString: qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
|
||||||
|
// Centered controls moved up
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if showingScanner {
|
||||||
|
Button(action: { showingScanner = false }) {
|
||||||
|
Label("show my qr", systemImage: "qrcode")
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
} else {
|
||||||
|
Button(action: { showingScanner = true }) {
|
||||||
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Remove verification for selected peer (if verified)
|
||||||
|
if let pid = viewModel.selectedPrivateChatPeer,
|
||||||
|
let fp = viewModel.getFingerprint(for: pid),
|
||||||
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
#endif
|
||||||
|
.onDisappear { showingScanner = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
</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
|
||||||
|
|
||||||
class ShareViewController: SLComposeServiceViewController {
|
/// Modern share extension using UIKit + UTTypes.
|
||||||
|
/// 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()
|
||||||
// Set placeholder text
|
view.backgroundColor = .systemBackground
|
||||||
placeholder = "Share to bitchat..."
|
view.addSubview(statusLabel)
|
||||||
// Set character limit (optional)
|
NSLayoutConstraint.activate([
|
||||||
charactersRemaining = 500
|
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
processShare()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func isContentValid() -> Bool {
|
// MARK: - Processing
|
||||||
// Validate that we have text content or attachments
|
private func processShare() {
|
||||||
if let text = contentText, !text.isEmpty {
|
guard let ctx = self.extensionContext,
|
||||||
return true
|
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||||
}
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// Get the page title from the compose view or extension item
|
// Scan attachments for URL/text
|
||||||
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
|
let providers = item.attachments ?? []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
var foundURL: URL? = nil
|
// Load URL or text asynchronously
|
||||||
let group = DispatchGroup()
|
loadFirstURL(from: providers) { [weak self] url in
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IMPORTANT: Check if the NSExtensionItem itself has a URL
|
private func detectURL(in text: String) -> URL? {
|
||||||
// Safari often provides the URL as an attributedString with a link
|
guard !text.isEmpty else { return nil }
|
||||||
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 matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
let range = NSRange(location: 0, length: (text as NSString).length)
|
||||||
if let firstMatch = matches?.first, let url = firstMatch.url {
|
let match = detector?.matches(in: text, options: [], range: range).first
|
||||||
foundURL = url
|
return match?.url
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check attachments if we haven't found a URL yet
|
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
||||||
if foundURL == nil {
|
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
||||||
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
|
let grp = DispatchGroup()
|
||||||
|
var found: URL?
|
||||||
|
|
||||||
// Try multiple URL type identifiers that Safari might use
|
for p in providers where found == nil {
|
||||||
let urlTypes = [
|
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
|
||||||
UTType.url.identifier,
|
grp.enter()
|
||||||
"public.url",
|
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
||||||
"public.file-url"
|
defer { grp.leave() }
|
||||||
]
|
if let u = item as? URL { found = u; return }
|
||||||
|
if let s = item as? String, let u = URL(string: s) { found = u; return }
|
||||||
for urlType in urlTypes {
|
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
|
||||||
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
|
}
|
||||||
group.enter()
|
break
|
||||||
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
|
|
||||||
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
|
grp.notify(queue: .main) { completion(found) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check for plain text that might be a URL
|
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
||||||
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
let id = UTType.plainText.identifier
|
||||||
group.enter()
|
let grp = DispatchGroup()
|
||||||
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
|
var text: String?
|
||||||
defer { group.leave() }
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
if let text = item as? String {
|
// MARK: - Save + Finish
|
||||||
// Check if the text is actually a URL
|
private func saveAndFinish(url: URL, title: String?) {
|
||||||
if let url = URL(string: text),
|
let payload: [String: String] = [
|
||||||
(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,
|
"url": url.absoluteString,
|
||||||
"title": pageTitle ?? url.host ?? "Shared Link"
|
"title": title ?? url.host ?? "Shared Link"
|
||||||
]
|
]
|
||||||
|
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
|
let s = String(data: json, encoding: .utf8) {
|
||||||
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
|
saveToSharedDefaults(content: s, type: "url")
|
||||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
finishWithMessage("✓ Shared link to bitchat")
|
||||||
self?.saveToSharedDefaults(content: jsonString, type: "url")
|
} else {
|
||||||
}
|
finishWithMessage("Failed to encode link")
|
||||||
} 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]! {
|
private func saveAndFinish(text: String) {
|
||||||
// No configuration items needed
|
saveToSharedDefaults(content: text, type: "text")
|
||||||
return []
|
finishWithMessage("✓ Shared text to bitchat")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
// Use app groups to share data between extension and main app
|
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
||||||
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")
|
||||||
userDefaults.synchronize()
|
// No need to force synchronize; the system persists changes
|
||||||
|
|
||||||
|
|
||||||
// Force open the main app
|
|
||||||
self.openMainApp()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openMainApp() {
|
private func finishWithMessage(_ msg: String) {
|
||||||
// Share extensions cannot directly open the containing app
|
statusLabel.text = msg
|
||||||
// The app will check for shared content when it becomes active
|
// Complete shortly after showing status
|
||||||
// Show success feedback to user
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||||
DispatchQueue.main.async {
|
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
self.textView.text = "✓ Shared to bitchat"
|
|
||||||
self.textView.isEditable = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,10 @@ 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!"
|
||||||
@@ -34,8 +36,10 @@ 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(
|
||||||
@@ -52,7 +56,9 @@ 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 {
|
||||||
@@ -75,8 +81,10 @@ 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(
|
||||||
@@ -109,7 +117,114 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||||
|
// Identities
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
|
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
XCTFail("Failed to embed delivered ack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure v2 format was used for ciphertext
|
||||||
|
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
// Decrypt as recipient
|
||||||
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify sender is correct
|
||||||
|
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||||
|
|
||||||
|
// Parse BitChat payload
|
||||||
|
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||||
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
|
guard let packetData = Self.base64URLDecode(base64url),
|
||||||
|
let packet = BitchatPacket.from(packetData) else {
|
||||||
|
return XCTFail("Failed to decode bitchat packet")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||||
|
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||||
|
return XCTFail("Failed to decode NoisePayload")
|
||||||
|
}
|
||||||
|
switch payload.type {
|
||||||
|
case .delivered:
|
||||||
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
|
XCTAssertEqual(mid, messageID)
|
||||||
|
default:
|
||||||
|
XCTFail("Unexpected payload type: \(payload.type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
||||||
|
// Identities
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
let messageID = "TEST-MSG-READ-1"
|
||||||
|
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
XCTFail("Failed to embed read ack")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
|
content: embedded,
|
||||||
|
recipientPubkey: recipient.publicKeyHex,
|
||||||
|
senderIdentity: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: recipient
|
||||||
|
)
|
||||||
|
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||||
|
|
||||||
|
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||||
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
|
guard let packetData = Self.base64URLDecode(base64url),
|
||||||
|
let packet = BitchatPacket.from(packetData) else {
|
||||||
|
return XCTFail("Failed to decode bitchat packet")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||||
|
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||||
|
return XCTFail("Failed to decode NoisePayload")
|
||||||
|
}
|
||||||
|
switch payload.type {
|
||||||
|
case .readReceipt:
|
||||||
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
|
XCTAssertEqual(mid, messageID)
|
||||||
|
default:
|
||||||
|
XCTFail("Unexpected payload type: \(payload.type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
let rem = str.count % 4
|
||||||
|
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||||
|
return Data(base64Encoded: str)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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