mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
Compare commits
50
Commits
v1.3.0
...
feat/georelay
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c55e2d4b44 | ||
|
|
e44ba3e5c7 | ||
|
|
f59139c33c | ||
|
|
d2bfbbcfd4 | ||
|
|
0260798712 | ||
|
|
2758afa126 | ||
|
|
2229a28bb4 | ||
|
|
dadc896ed8 | ||
|
|
b2504a7ff5 | ||
|
|
7f2cbd6621 | ||
|
|
60a42ffecb | ||
|
|
27e5e2962c | ||
|
|
9d5105a8bf | ||
|
|
d4b779080a | ||
|
|
4bfe9ac80d | ||
|
|
adffe7dfd6 | ||
|
|
16fdb7b49e | ||
|
|
ff5fd3f3fe | ||
|
|
f8a955214b | ||
|
|
d5e712e27f | ||
|
|
244c8cdf81 | ||
|
|
0d064084aa | ||
|
|
7b49268694 | ||
|
|
5650e1f2c2 | ||
|
|
c4b422ad3f | ||
|
|
e82fda1093 | ||
|
|
806d451135 | ||
|
|
13f8b0c636 | ||
|
|
2d0f9aff0a | ||
|
|
83ee5abb60 | ||
|
|
bc27e16899 | ||
|
|
d3d9a22757 | ||
|
|
8a269d4fec | ||
|
|
33fbca67d6 | ||
|
|
c63350a4d3 | ||
|
|
222854c60a | ||
|
|
c624611e7d | ||
|
|
eb0debd52a | ||
|
|
916f535503 | ||
|
|
de39ab6687 | ||
|
|
a0a973eb81 | ||
|
|
ba1dd100ec | ||
|
|
97b1463b30 | ||
|
|
28c87a41c1 | ||
|
|
68c61476d8 | ||
|
|
602e93d1b2 | ||
|
|
e7706fc9cb | ||
|
|
f30677403f | ||
|
|
b4fcea2672 | ||
|
|
43166bcd64 |
+1
-1
@@ -98,7 +98,7 @@ While the Noise handshake cryptographically authenticates a peer's key, it doesn
|
|||||||
### 4.2. Favorites and Blocking
|
### 4.2. Favorites and Blocking
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
To improve the user experience and provide control over interactions, the protocol supports:
|
||||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites." This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
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 */; };
|
||||||
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
|
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
|
||||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
|
|
||||||
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; };
|
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; };
|
||||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
|
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
|
||||||
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
|
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
|
||||||
@@ -82,7 +81,6 @@
|
|||||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
|
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
|
||||||
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
|
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
|
||||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; };
|
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; };
|
||||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
|
|
||||||
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; };
|
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; };
|
||||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
||||||
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; };
|
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; };
|
||||||
@@ -106,6 +104,8 @@
|
|||||||
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.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 */; };
|
||||||
|
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 */; };
|
||||||
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 */; };
|
||||||
@@ -126,6 +126,10 @@
|
|||||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
||||||
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
||||||
@@ -233,9 +237,9 @@
|
|||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
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>"; };
|
||||||
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
@@ -243,6 +247,8 @@
|
|||||||
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
||||||
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
||||||
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -302,6 +308,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2F82C5FC8433F4064F079D1F /* bitchat */,
|
2F82C5FC8433F4064F079D1F /* bitchat */,
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
@@ -321,8 +328,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
||||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
|
||||||
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
||||||
|
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
||||||
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
||||||
EA706D8E5097785414646A8E /* Info.plist */,
|
EA706D8E5097785414646A8E /* Info.plist */,
|
||||||
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
||||||
@@ -427,7 +434,7 @@
|
|||||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||||
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */,
|
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -519,6 +526,7 @@
|
|||||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||||
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
|
||||||
);
|
);
|
||||||
path = Nostr;
|
path = Nostr;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -683,6 +691,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -692,6 +701,7 @@
|
|||||||
files = (
|
files = (
|
||||||
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -729,7 +739,6 @@
|
|||||||
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
|
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
|
||||||
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
||||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
||||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
|
||||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||||
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
||||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||||
@@ -756,6 +765,8 @@
|
|||||||
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||||
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -782,7 +793,6 @@
|
|||||||
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
|
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
|
||||||
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
||||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
||||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
|
||||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||||
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
||||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||||
@@ -809,6 +819,8 @@
|
|||||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||||
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -960,7 +972,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -991,7 +1003,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1046,7 +1058,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1078,7 +1090,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1167,7 +1179,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1260,7 +1272,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -44,8 +46,8 @@ struct BitchatApp: App {
|
|||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
switch newPhase {
|
switch newPhase {
|
||||||
case .background:
|
case .background:
|
||||||
// Send leave message when going to background
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
chatViewModel.meshService.stopServices()
|
break
|
||||||
case .active:
|
case .active:
|
||||||
// Restart services when becoming active
|
// Restart services when becoming active
|
||||||
chatViewModel.meshService.startServices()
|
chatViewModel.meshService.startServices()
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ struct IdentityCache: Codable {
|
|||||||
// Last interaction timestamps (privacy: optional)
|
// Last interaction timestamps (privacy: optional)
|
||||||
var lastInteractions: [String: Date] = [:]
|
var lastInteractions: [String: Date] = [:]
|
||||||
|
|
||||||
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
// Schema version for future migrations
|
// Schema version for future migrations
|
||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,30 @@ class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash (Nostr) Blocking
|
||||||
|
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||||
|
let key = pubkeyHexLowercased.lowercased()
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
if isBlocked {
|
||||||
|
self.cache.blockedNostrPubkeys.insert(key)
|
||||||
|
} else {
|
||||||
|
self.cache.blockedNostrPubkeys.remove(key)
|
||||||
|
}
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBlockedNostrPubkeys() -> Set<String> {
|
||||||
|
queue.sync { cache.blockedNostrPubkeys }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
|
@MainActor
|
||||||
|
final class GeoRelayDirectory {
|
||||||
|
struct Entry: Hashable {
|
||||||
|
let host: String
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = GeoRelayDirectory()
|
||||||
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
|
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load cached or bundled data synchronously
|
||||||
|
self.entries = self.loadLocalEntries()
|
||||||
|
// Fire-and-forget remote refresh if stale
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
|
let center = Geohash.decodeCenter(geohash)
|
||||||
|
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
|
.sorted { a, b in
|
||||||
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
|
}
|
||||||
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Fetch
|
||||||
|
func prefetchIfNeeded() {
|
||||||
|
let now = Date()
|
||||||
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
fetchRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRemote() {
|
||||||
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
|
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
|
if !parsed.isEmpty {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.entries = parsed
|
||||||
|
self.persistCache(text)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistCache(_ text: String) {
|
||||||
|
guard let url = cacheURL() else { return }
|
||||||
|
do {
|
||||||
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
private func loadLocalEntries() -> [Entry] {
|
||||||
|
// Prefer cached file if present
|
||||||
|
if let cache = self.cacheURL(),
|
||||||
|
let data = try? Data(contentsOf: cache),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
// Try bundled resource(s)
|
||||||
|
let bundleCandidates = [
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
for url in bundleCandidates {
|
||||||
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try filesystem path (development/test)
|
||||||
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
return Self.parseCSV(text)
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
|
var result: Set<Entry> = []
|
||||||
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
|
for (idx, raw) in lines.enumerated() {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count >= 3 else { continue }
|
||||||
|
var host = parts[0]
|
||||||
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "http://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||||
|
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
|
}
|
||||||
|
return Array(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheURL() -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Distance
|
||||||
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||||
|
let r = 6371.0 // Earth radius in km
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLon = (lon2 - lon1) * .pi / 180
|
||||||
|
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
return r * c
|
||||||
|
}
|
||||||
@@ -5,13 +5,16 @@ import Security
|
|||||||
|
|
||||||
// Keychain helper for secure storage
|
// Keychain helper for secure storage
|
||||||
struct KeychainHelper {
|
struct KeychainHelper {
|
||||||
static func save(key: String, data: Data, service: String) {
|
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
let query: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data
|
kSecValueData as String: data
|
||||||
]
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
@@ -106,6 +109,8 @@ struct NostrIdentityBridge {
|
|||||||
private static let keychainService = "chat.bitchat.nostr"
|
private static let keychainService = "chat.bitchat.nostr"
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
private static let currentIdentityKey = "nostr-current-identity"
|
||||||
private static let deviceSeedKey = "nostr-device-seed"
|
private static let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private static var deviceSeedCache: Data?
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
/// Get or create the current Nostr identity
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
@@ -159,14 +164,20 @@ struct NostrIdentityBridge {
|
|||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
/// Stored only on device keychain.
|
/// Stored only on device keychain.
|
||||||
private static func getOrCreateDeviceSeed() -> Data {
|
private static func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
var seed = Data(count: 32)
|
var seed = Data(count: 32)
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService)
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,16 @@ struct NostrProtocol {
|
|||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil
|
nickname: String? = nil,
|
||||||
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname, !nickname.isEmpty {
|
if let nickname = nickname, !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
|
if teleported {
|
||||||
|
tags.append(["t", "teleport"])
|
||||||
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// Default relay list (can be customized)
|
// Default relay list (can be customized)
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub",
|
||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
@@ -77,9 +78,23 @@ class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
|
let existing = Set(relays.map { $0.url })
|
||||||
|
for url in Set(relayUrls) {
|
||||||
|
if !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
}
|
||||||
|
if connections[url] == nil {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
let targetRelays = relayUrls ?? relays.map { $0.url }
|
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Add to queue for reliability
|
// Add to queue for reliability
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
@@ -94,10 +109,11 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
handler: @escaping (NostrEvent) -> Void
|
||||||
) {
|
) {
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
@@ -119,8 +135,14 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
||||||
// category: SecureLogger.session, level: .debug)
|
// category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send subscription to all connected relays
|
// Target specific relays if provided; else all connections
|
||||||
for (relayUrl, connection) in connections {
|
let urls = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: urls)
|
||||||
|
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
|
||||||
|
connections[url].map { (url, $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (relayUrl, connection) in targets {
|
||||||
connection.send(.string(messageString)) { error in
|
connection.send(.string(messageString)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
||||||
|
|||||||
@@ -410,12 +410,12 @@ class BitchatMessage: Codable {
|
|||||||
// Cached formatted text (not included in Codable)
|
// Cached formatted text (not included in Codable)
|
||||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
func getCachedFormattedText(isDark: Bool) -> AttributedString? {
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
return _cachedFormattedText["\(isDark)"]
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) {
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
_cachedFormattedText["\(isDark)"] = text
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codable implementation
|
// Codable implementation
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import Foundation
|
|||||||
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||||
enum Geohash {
|
enum Geohash {
|
||||||
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
private static let base32Map: [Character: Int] = {
|
||||||
|
var map: [Character: Int] = [:]
|
||||||
|
for (i, c) in base32Chars.enumerated() { map[c] = i }
|
||||||
|
return map
|
||||||
|
}()
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -56,5 +61,30 @@ enum Geohash {
|
|||||||
|
|
||||||
return String(geohash)
|
return String(geohash)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Decodes a geohash into the center latitude/longitude of its bounding box.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: (lat, lon) center coordinate.
|
||||||
|
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {
|
||||||
|
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||||
|
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||||
|
|
||||||
|
var isEven = true
|
||||||
|
for ch in geohash.lowercased() {
|
||||||
|
guard let cd = base32Map[ch] else { continue }
|
||||||
|
for mask in [16, 8, 4, 2, 1] {
|
||||||
|
if isEven {
|
||||||
|
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||||
|
} else {
|
||||||
|
let mid = (latInterval.0 + latInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||||
|
}
|
||||||
|
isEven.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lat = (latInterval.0 + latInterval.1) / 2
|
||||||
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
return (lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,33 +2,70 @@ import Foundation
|
|||||||
|
|
||||||
/// Levels of location channels mapped to geohash precisions.
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
case street
|
|
||||||
case block
|
case block
|
||||||
case neighborhood
|
case neighborhood
|
||||||
case city
|
case city
|
||||||
case region
|
case province // previously .region
|
||||||
case country
|
case region // previously .country
|
||||||
|
|
||||||
/// Geohash length used for this level.
|
/// Geohash length used for this level.
|
||||||
var precision: Int {
|
var precision: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .street: return 8
|
|
||||||
case .block: return 7
|
case .block: return 7
|
||||||
case .neighborhood: return 6
|
case .neighborhood: return 6
|
||||||
case .city: return 5
|
case .city: return 5
|
||||||
case .region: return 4
|
case .province: return 4
|
||||||
case .country: return 2
|
case .region: return 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .street: return "Street"
|
|
||||||
case .block: return "Block"
|
case .block: return "Block"
|
||||||
case .neighborhood: return "Neighborhood"
|
case .neighborhood: return "Neighborhood"
|
||||||
case .city: return "City"
|
case .city: return "City"
|
||||||
|
case .province: return "Province"
|
||||||
case .region: return "Region"
|
case .region: return "Region"
|
||||||
case .country: return "Country"
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backward-compatible Codable for renamed cases
|
||||||
|
extension GeohashChannelLevel {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let raw = try? container.decode(String.self) {
|
||||||
|
switch raw {
|
||||||
|
case "block": self = .block
|
||||||
|
case "neighborhood": self = .neighborhood
|
||||||
|
case "city": self = .city
|
||||||
|
case "region": self = .province // old "region" maps to new .province
|
||||||
|
case "country": self = .region // old "country" maps to new .region
|
||||||
|
case "province": self = .province
|
||||||
|
default:
|
||||||
|
self = .block
|
||||||
|
}
|
||||||
|
} else if let precision = try? container.decode(Int.self) {
|
||||||
|
switch precision {
|
||||||
|
case 7: self = .block
|
||||||
|
case 6: self = .neighborhood
|
||||||
|
case 5: self = .city
|
||||||
|
case 4: self = .province
|
||||||
|
case 0...3: self = .region
|
||||||
|
default: self = .block
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self = .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .block: try container.encode("block")
|
||||||
|
case .neighborhood: try container.encode("neighborhood")
|
||||||
|
case .city: try container.encode("city")
|
||||||
|
case .province: try container.encode("province")
|
||||||
|
case .region: try container.encode("region")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,4 +105,3 @@ enum ChannelID: Equatable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ class CommandProcessor {
|
|||||||
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// In public chat: send to mesh and also add a local system echo so sender sees it immediately
|
// In public chat: send to active public channel (mesh or geohash)
|
||||||
meshService?.sendMessage(emoteContent, mentions: [])
|
chatViewModel?.sendPublicRaw(emoteContent)
|
||||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
chatViewModel?.addPublicSystemMessage(publicEcho)
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
}
|
}
|
||||||
@@ -149,38 +149,47 @@ class CommandProcessor {
|
|||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else {
|
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||||
return .success(message: "no blocked peers")
|
|
||||||
}
|
|
||||||
|
|
||||||
var blockedNicknames: [String] = []
|
var blockedNicknames: [String] = []
|
||||||
if let peers = meshService?.getPeerNicknames() {
|
if let peers = meshService?.getPeerNicknames() {
|
||||||
for (peerID, nickname) in peers {
|
for (peerID, nickname) in peers {
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID),
|
if let fingerprint = meshService?.getFingerprint(for: peerID),
|
||||||
blockedUsers.contains(fingerprint) {
|
meshBlocked.contains(fingerprint) {
|
||||||
blockedNicknames.append(nickname)
|
blockedNicknames.append(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)"
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
: blockedNicknames.sorted().joined(separator: ", ")
|
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||||
return .success(message: "blocked peers: \(list)")
|
var geoNames: [String] = []
|
||||||
|
if let vm = chatViewModel {
|
||||||
|
let visible = vm.visibleGeohashPeople()
|
||||||
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
|
for pk in geoBlocked {
|
||||||
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
|
geoNames.append(name)
|
||||||
|
} else {
|
||||||
|
let suffix = String(pk.suffix(4))
|
||||||
|
geoNames.append("anon#\(suffix)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
|
||||||
|
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
|
||||||
|
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
|
||||||
}
|
|
||||||
|
|
||||||
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
|
// Block the user (mesh/noise identity)
|
||||||
// Block the user
|
|
||||||
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||||
identity.isBlocked = true
|
identity.isBlocked = true
|
||||||
identity.isFavorite = false
|
identity.isFavorite = false
|
||||||
@@ -197,11 +206,19 @@ class CommandProcessor {
|
|||||||
)
|
)
|
||||||
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The peerStateManager and SecureIdentityStateManager handle the blocking state
|
|
||||||
|
|
||||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
}
|
}
|
||||||
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
|
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
|
return .success(message: "\(nickname) is already blocked")
|
||||||
|
}
|
||||||
|
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true)
|
||||||
|
return .success(message: "blocked \(nickname) in geohash chats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||||
|
}
|
||||||
|
|
||||||
private func handleUnblock(_ args: String) -> CommandResult {
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
@@ -211,20 +228,24 @@ class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
return .error(message: "cannot unblock \(nickname): not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
||||||
// The SecureIdentityStateManager handles the unblocking state
|
|
||||||
|
|
||||||
return .success(message: "unblocked \(nickname)")
|
return .success(message: "unblocked \(nickname)")
|
||||||
}
|
}
|
||||||
|
// Try geohash unblock
|
||||||
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
|
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
|
return .success(message: "\(nickname) is not blocked")
|
||||||
|
}
|
||||||
|
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false)
|
||||||
|
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||||
|
}
|
||||||
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
|||||||
@@ -17,14 +17,23 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
private let cl = CLLocationManager()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
private var lastLocation: CLLocation?
|
private var lastLocation: CLLocation?
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
private let userDefaultsKey = "locationChannel.selected"
|
private let userDefaultsKey = "locationChannel.selected"
|
||||||
|
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||||
|
private var isGeocoding: Bool = false
|
||||||
|
|
||||||
// Published state for UI bindings
|
// Published state for UI bindings
|
||||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||||
|
// True when the current location channel was selected via manual teleport
|
||||||
|
@Published var teleported: Bool = false
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||||
|
|
||||||
|
// Persisted set of geohashes that were selected via teleport
|
||||||
|
private var teleportedSet: Set<String> = []
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -36,6 +45,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
selectedChannel = channel
|
selectedChannel = channel
|
||||||
}
|
}
|
||||||
|
// Load persisted teleported set
|
||||||
|
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
teleportedSet = Set(arr)
|
||||||
|
}
|
||||||
|
// Initialize teleported flag from persisted state if a location channel is selected
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, *) {
|
if #available(iOS 14.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
@@ -76,15 +94,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
|
|
||||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||||
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||||
// Prefer continuous updates with a significant distance filter rather than polling
|
|
||||||
guard permissionState == .authorized else { return }
|
guard permissionState == .authorized else { return }
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||||
cl.distanceFilter = 21 // meters; update on small moves
|
refreshTimer?.invalidate()
|
||||||
cl.startUpdatingLocation()
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
self?.requestOneShotLocation()
|
||||||
|
}
|
||||||
|
// Kick off immediately
|
||||||
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop periodic refreshes when selector UI is dismissed.
|
/// Stop periodic refreshes when selector UI is dismissed.
|
||||||
func endLiveRefresh() {
|
func endLiveRefresh() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
cl.stopUpdatingLocation()
|
cl.stopUpdatingLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +117,24 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
if let data = try? JSONEncoder().encode(channel) {
|
if let data = try? JSONEncoder().encode(channel) {
|
||||||
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||||
}
|
}
|
||||||
|
// Update teleported flag based on persisted state for immediate UI behavior
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||||
|
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||||
|
}
|
||||||
|
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||||
|
Task { @MainActor in self.teleported = flag }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +164,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
guard let loc = locations.last else { return }
|
guard let loc = locations.last else { return }
|
||||||
lastLocation = loc
|
lastLocation = loc
|
||||||
computeChannels(from: loc.coordinate)
|
computeChannels(from: loc.coordinate)
|
||||||
|
reverseGeocodeIfNeeded(location: loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
@@ -151,7 +193,67 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||||
result.append(GeohashChannel(level: level, geohash: gh))
|
result.append(GeohashChannel(level: level, geohash: gh))
|
||||||
}
|
}
|
||||||
Task { @MainActor in self.availableChannels = result }
|
Task { @MainActor in
|
||||||
|
self.availableChannels = result
|
||||||
|
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||||
|
switch self.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||||
|
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||||
|
self.teleported = persisted || (currentGH != ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||||
|
// Always cancel previous to keep latest fresh while user moves
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
isGeocoding = true
|
||||||
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isGeocoding = false
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let names = self.namesByLevel(from: pm)
|
||||||
|
Task { @MainActor in self.locationNames = names }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||||
|
var dict: [GeohashChannelLevel: String] = [:]
|
||||||
|
// Region (country)
|
||||||
|
if let country = pm.country, !country.isEmpty {
|
||||||
|
dict[.region] = country
|
||||||
|
}
|
||||||
|
// Province (state/province or county)
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.province] = admin
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.province] = subAdmin
|
||||||
|
}
|
||||||
|
// City (locality)
|
||||||
|
if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.city] = locality
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.city] = subAdmin
|
||||||
|
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.city] = admin
|
||||||
|
}
|
||||||
|
// Neighborhood
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.neighborhood] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.neighborhood] = locality
|
||||||
|
}
|
||||||
|
// Block: reuse neighborhood/locality granularity without exposing street level
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.block] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.block] = locality
|
||||||
|
}
|
||||||
|
return dict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@ struct AppInfoView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("DONE") {
|
Button("close") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|||||||
+437
-96
@@ -25,32 +25,7 @@ struct PeerDisplayData: Identifiable {
|
|||||||
let isMutualFavorite: Bool
|
let isMutualFavorite: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lazy Link Preview
|
// (Link previews removed; URLs are now clickable inline)
|
||||||
|
|
||||||
// Lazy loading wrapper for link previews
|
|
||||||
struct LazyLinkPreviewView: View {
|
|
||||||
let url: URL
|
|
||||||
let title: String?
|
|
||||||
@State private var isVisible = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
if isVisible {
|
|
||||||
LinkPreviewView(url: url, title: title)
|
|
||||||
} else {
|
|
||||||
// Placeholder while not visible
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color.gray.opacity(0.1))
|
|
||||||
.frame(height: 80)
|
|
||||||
.onAppear {
|
|
||||||
// Only load when view appears on screen
|
|
||||||
isVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Main Content View
|
// MARK: - Main Content View
|
||||||
|
|
||||||
@@ -77,10 +52,16 @@ struct ContentView: View {
|
|||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: String?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
|
@State private var isAtBottomPublic: Bool = true
|
||||||
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@State private var lastScrollTime: Date = .distantPast
|
@State private var lastScrollTime: Date = .distantPast
|
||||||
@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 expandedMessageIDs: Set<String> = []
|
||||||
|
// Window sizes for rendering (infinite scroll up)
|
||||||
|
@State private var windowCountPublic: Int = 300
|
||||||
|
@State private var windowCountPrivate: [String: Int] = [:]
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
@@ -103,6 +84,10 @@ struct ContentView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// Base layer - Main public chat (always visible)
|
// Base layer - Main public chat (always visible)
|
||||||
mainChatView
|
mainChatView
|
||||||
|
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||||
|
.onChange(of: colorScheme) { newValue in
|
||||||
|
viewModel.currentColorScheme = newValue
|
||||||
|
}
|
||||||
|
|
||||||
// Private chat slide-over
|
// Private chat slide-over
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
@@ -204,7 +189,15 @@ struct ContentView: View {
|
|||||||
isPresented: $showMessageActions,
|
isPresented: $showMessageActions,
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("private message") {
|
Button("mention") {
|
||||||
|
if let sender = selectedMessageSender {
|
||||||
|
// Pre-fill the input with an @mention and focus the field
|
||||||
|
messageText = "@\(sender) "
|
||||||
|
isTextFieldFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("direct message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.hasPrefix("nostr:") {
|
||||||
@@ -237,9 +230,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button("BLOCK", role: .destructive) {
|
Button("BLOCK", role: .destructive) {
|
||||||
if let sender = selectedMessageSender {
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
|
#if os(iOS)
|
||||||
|
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
||||||
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
|
let sender = selectedMessageSender {
|
||||||
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
|
} 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) {}
|
||||||
@@ -265,7 +267,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
|
|
||||||
private func messagesView(privatePeer: String?) -> some View {
|
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -279,27 +281,48 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Implement windowing - show last 100 messages for performance
|
// Implement windowing with adjustable window count per chat
|
||||||
let windowedMessages = messages.suffix(100)
|
let currentWindowCount: Int = {
|
||||||
|
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
|
||||||
|
return windowCountPublic
|
||||||
|
}()
|
||||||
|
let windowedMessages = messages.suffix(currentWindowCount)
|
||||||
|
|
||||||
ForEach(windowedMessages, id: \.id) { message in
|
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
|
||||||
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
|
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||||
|
|
||||||
|
ForEach(items, id: \.uiID) { item in
|
||||||
|
let message = item.message
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Check if current user is mentioned
|
// Check if current user is mentioned
|
||||||
|
|
||||||
if message.sender == "system" {
|
if message.sender == "system" {
|
||||||
// System messages
|
// System messages
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.textSelection(.enabled)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
// Regular messages with natural text wrapping
|
// Regular messages with natural text wrapping
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Precompute heavy token scans once per row
|
||||||
|
let cashuTokens = message.content.extractCashuTokens()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
// Single text view for natural wrapping
|
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.textSelection(.enabled)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? 30 : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
@@ -310,55 +333,290 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for plain URLs
|
// Expand/Collapse for very long messages
|
||||||
let urls = message.content.extractURLs()
|
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
|
||||||
if !urls.isEmpty {
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
ForEach(urls.prefix(3).indices, id: \.self) { index in
|
Button(isExpanded ? "show less" : "show more") {
|
||||||
let urlInfo = urls[index]
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
LazyLinkPreviewView(url: urlInfo.url, title: nil)
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
.padding(.top, 3)
|
}
|
||||||
.padding(.horizontal, 1)
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
.id("\(message.id)-\(urlInfo.url.absoluteString)")
|
.foregroundColor(Color.blue)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render payment chips (Lightning / Cashu) with rounded background
|
||||||
|
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
|
||||||
|
let link = lightningLinks[i]
|
||||||
|
PaymentChipView(
|
||||||
|
emoji: "⚡",
|
||||||
|
label: "pay via lightning",
|
||||||
|
colorScheme: colorScheme
|
||||||
|
) {
|
||||||
|
#if os(iOS)
|
||||||
|
if let url = URL(string: link) { UIApplication.shared.open(url) }
|
||||||
|
#else
|
||||||
|
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { i in
|
||||||
|
let token = cashuTokens[i]
|
||||||
|
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||||
|
let urlStr = "cashu:\(enc)"
|
||||||
|
PaymentChipView(
|
||||||
|
emoji: "🥜",
|
||||||
|
label: "pay via cashu",
|
||||||
|
colorScheme: colorScheme
|
||||||
|
) {
|
||||||
|
#if os(iOS)
|
||||||
|
if let url = URL(string: urlStr) { UIApplication.shared.open(url) }
|
||||||
|
#else
|
||||||
|
if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.id(item.uiID)
|
||||||
|
.onAppear {
|
||||||
|
// Track if last item is visible to enable auto-scroll only when near bottom
|
||||||
|
if message.id == windowedMessages.last?.id {
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
}
|
||||||
|
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
||||||
|
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
||||||
|
let step = 200
|
||||||
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
|
if let peer = privatePeer {
|
||||||
|
let current = windowCountPrivate[peer] ?? 300
|
||||||
|
let newCount = min(messages.count, current + step)
|
||||||
|
if newCount != current {
|
||||||
|
windowCountPrivate[peer] = newCount
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
proxy.scrollTo(preserveID, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let current = windowCountPublic
|
||||||
|
let newCount = min(messages.count, current + step)
|
||||||
|
if newCount != current {
|
||||||
|
windowCountPublic = newCount
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
proxy.scrollTo(preserveID, anchor: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(message.id)
|
.onDisappear {
|
||||||
|
if message.id == windowedMessages.last?.id {
|
||||||
|
isAtBottom.wrappedValue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
// Only show actions for messages from other users (not system or self)
|
// Tap on message body: insert @mention for this sender
|
||||||
if message.sender != "system" && message.sender != viewModel.nickname {
|
if message.sender != "system" {
|
||||||
selectedMessageSender = message.sender
|
let name = message.sender
|
||||||
selectedMessageSenderID = message.senderPeerID
|
messageText = "@\(name) "
|
||||||
showMessageActions = true
|
isTextFieldFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button("Copy message") {
|
||||||
|
#if os(iOS)
|
||||||
|
UIPasteboard.general.string = message.content
|
||||||
|
#else
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
pb.setString(message.content, forType: .string)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||||
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
let peerID = id.removingPercentEncoding ?? id
|
||||||
|
selectedMessageSenderID = peerID
|
||||||
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender
|
||||||
|
showMessageActions = true
|
||||||
|
}
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard url.scheme == "bitchat", url.host == "geohash" else { return }
|
||||||
|
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
|
||||||
|
#if os(iOS)
|
||||||
|
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||||
|
switch len {
|
||||||
|
case 0...2: return .region
|
||||||
|
case 3...4: return .province
|
||||||
|
case 5: return .city
|
||||||
|
case 6: return .neighborhood
|
||||||
|
case 7: return .block
|
||||||
|
default: return .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let level = levelForLength(gh.count)
|
||||||
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
|
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
|
||||||
viewModel.sendMessage("/clear")
|
viewModel.sendMessage("/clear")
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Force scroll to bottom when opening a chat view
|
||||||
|
let targetID: String? = {
|
||||||
|
if let peer = privatePeer,
|
||||||
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
|
return "dm:\(peer)|\(last)"
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
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)" }
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
|
// Second pass after a brief delay to handle late layout
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
let targetID2: String? = {
|
||||||
|
if let peer = privatePeer,
|
||||||
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
|
return "dm:\(peer)|\(last)"
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
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)" }
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: privatePeer) { _ in
|
||||||
|
// When switching to a different private chat, jump to bottom
|
||||||
|
let targetID: String? = {
|
||||||
|
if let peer = privatePeer,
|
||||||
|
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
|
return "dm:\(peer)|\(last)"
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
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)" }
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: viewModel.messages.count) { _ in
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
if privatePeer == nil && !viewModel.messages.isEmpty {
|
if privatePeer == nil && !viewModel.messages.isEmpty {
|
||||||
|
// If the newest message is from me, always scroll to bottom
|
||||||
|
let lastMsg = viewModel.messages.last!
|
||||||
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
|
if !isFromSelf {
|
||||||
|
// Only autoscroll when user is at/near bottom
|
||||||
|
guard isAtBottom.wrappedValue else { return }
|
||||||
|
} else {
|
||||||
|
// Ensure we consider ourselves at bottom for subsequent messages
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
}
|
||||||
// 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) > 0.5 {
|
||||||
// Immediate scroll if enough time has passed
|
// Immediate scroll if enough time has passed
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
|
let count = windowCountPublic
|
||||||
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
} 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: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
|
#if os(iOS)
|
||||||
|
let contextKey: String = {
|
||||||
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
#else
|
||||||
|
let contextKey: String = "mesh"
|
||||||
|
#endif
|
||||||
|
let count = windowCountPublic
|
||||||
|
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,20 +625,59 @@ struct ContentView: View {
|
|||||||
if let peerID = privatePeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[peerID],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
|
// If the newest private message is from me, always scroll
|
||||||
|
let lastMsg = messages.last!
|
||||||
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
|
if !isFromSelf {
|
||||||
|
// Only autoscroll when user is at/near bottom
|
||||||
|
guard isAtBottom.wrappedValue else { return }
|
||||||
|
} else {
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
}
|
||||||
// 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) > 0.5 {
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
|
let contextKey = "dm:\(peerID)"
|
||||||
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
|
let contextKey = "dm:\(peerID)"
|
||||||
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.onChange(of: locationManager.selectedChannel) { newChannel in
|
||||||
|
// When switching to a new geohash channel, scroll to the bottom
|
||||||
|
guard privatePeer == nil else { return }
|
||||||
|
switch newChannel {
|
||||||
|
case .mesh:
|
||||||
|
break
|
||||||
|
case .location(let ch):
|
||||||
|
// Reset window size
|
||||||
|
windowCountPublic = 300
|
||||||
|
let contextKey = "geo:\(ch.geohash)"
|
||||||
|
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
||||||
|
let target = last.map { "\(contextKey)|\($0)" }
|
||||||
|
isAtBottom.wrappedValue = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = privatePeer {
|
if let peerID = privatePeer {
|
||||||
@@ -397,6 +694,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
// Intercept custom cashu: links created in attributed text
|
||||||
|
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
|
||||||
|
#if os(iOS)
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
return .handled
|
||||||
|
#else
|
||||||
|
// On non-iOS platforms, let the system handle or ignore
|
||||||
|
return .systemAction
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Input View
|
// MARK: - Input View
|
||||||
@@ -508,10 +818,7 @@ struct ContentView: View {
|
|||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.focused($isTextFieldFocused)
|
.focused($isTextFieldFocused)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.autocorrectionDisabled(true)
|
// iOS keyboard autocomplete and capitalization enabled by default
|
||||||
#if os(iOS)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
#endif
|
|
||||||
.onChange(of: messageText) { newValue in
|
.onChange(of: messageText) { newValue in
|
||||||
// Cancel previous debounce timer
|
// Cancel previous debounce timer
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
@@ -695,7 +1002,7 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
mainHeaderView
|
mainHeaderView
|
||||||
Divider()
|
Divider()
|
||||||
messagesView(privatePeer: nil)
|
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||||
Divider()
|
Divider()
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
@@ -745,7 +1052,7 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
privateHeaderView
|
privateHeaderView
|
||||||
Divider()
|
Divider()
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||||
Divider()
|
Divider()
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
@@ -834,20 +1141,17 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// People counter with unread indicator
|
// Channel badge + dynamic spacing + people counter
|
||||||
HStack(spacing: 4) {
|
// Precompute header count and color outside the ViewBuilder expressions
|
||||||
if viewModel.hasAnyUnreadMessages {
|
|
||||||
Image(systemName: "envelope.fill")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(Color.orange)
|
|
||||||
.accessibilityLabel("Unread private messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
// People count depends on active channel
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let cc = channelPeopleCountAndColor()
|
let cc = channelPeopleCountAndColor()
|
||||||
let otherPeersCount = cc.0
|
let headerCountColor: Color = cc.1
|
||||||
let countColor = cc.1
|
let headerOtherPeersCount: Int = {
|
||||||
|
if case .location = locationManager.selectedChannel {
|
||||||
|
return viewModel.visibleGeohashPeople().count
|
||||||
|
}
|
||||||
|
return cc.0
|
||||||
|
}()
|
||||||
#else
|
#else
|
||||||
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
let peerCounts = 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 }
|
||||||
@@ -855,31 +1159,39 @@ struct ContentView: View {
|
|||||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
else if peer.isMutualFavorite { counts.others += 1 }
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
}
|
}
|
||||||
let otherPeersCount = peerCounts.others
|
let headerOtherPeersCount = peerCounts.others
|
||||||
// Darker, more neutral blue (less purple hue)
|
// 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 countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
|
#if os(iOS)
|
||||||
|
if viewModel.hasAnyUnreadMessages {
|
||||||
|
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Open unread private chat")
|
||||||
|
}
|
||||||
// Location channels button '#'
|
// Location channels button '#'
|
||||||
#if os(iOS)
|
|
||||||
Button(action: { showLocationChannelsSheet = true }) {
|
Button(action: { showLocationChannelsSheet = true }) {
|
||||||
#if os(iOS)
|
|
||||||
let badgeText: String = {
|
let badgeText: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh:
|
case .mesh: return "#mesh"
|
||||||
return "#mesh"
|
case .location(let ch): return "#\(ch.geohash)"
|
||||||
case .location(let ch):
|
|
||||||
return "#\(ch.geohash)"
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let badgeColor: Color = {
|
let badgeColor: Color = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
// Darker, more neutral blue (less purple hue)
|
|
||||||
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
case .location:
|
case .location:
|
||||||
// Standard green to avoid overly bright appearance in light mode
|
|
||||||
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -887,31 +1199,23 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(badgeColor)
|
.foregroundColor(badgeColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.head)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing)
|
.layoutPriority(2)
|
||||||
.fixedSize(horizontal: false, vertical: false)
|
|
||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel("location channels")
|
||||||
#else
|
|
||||||
Text("#")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
.accessibilityLabel("location channels")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, 6)
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
Image(systemName: "person.2.fill")
|
Image(systemName: "person.2.fill")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")")
|
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||||
Text("\(otherPeersCount)")
|
Text("\(headerOtherPeersCount)")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(countColor)
|
.foregroundColor(headerCountColor)
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
@@ -1095,6 +1399,44 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Helper Views
|
// MARK: - Helper Views
|
||||||
|
|
||||||
|
// Rounded payment chip button
|
||||||
|
private struct PaymentChipView: View {
|
||||||
|
let emoji: String
|
||||||
|
let label: String
|
||||||
|
let colorScheme: ColorScheme
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
private var fgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
}
|
||||||
|
private var bgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||||
|
}
|
||||||
|
private var border: Color { fgColor.opacity(0.25) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(emoji)
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(bgColor)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(fgColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
||||||
struct MessageContentView: View {
|
struct MessageContentView: View {
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
@@ -1106,7 +1448,6 @@ struct MessageContentView: View {
|
|||||||
Text(message.content)
|
Text(message.content)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.fontWeight(isMentioned ? .bold : .regular)
|
.fontWeight(isMentioned ? .bold : .regular)
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ struct GeohashPeopleList: View {
|
|||||||
let textColor: Color
|
let textColor: Color
|
||||||
let secondaryTextColor: Color
|
let secondaryTextColor: Color
|
||||||
let onTapPerson: () -> Void
|
let onTapPerson: () -> Void
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
if viewModel.geohashPeople.isEmpty {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
@@ -23,29 +26,73 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
let ordered = viewModel.geohashPeople.sorted { a, b in
|
let people = viewModel.visibleGeohashPeople()
|
||||||
if let me = myHex {
|
let currentIDs = people.map { $0.id }
|
||||||
if a.id == me && b.id != me { return true }
|
|
||||||
if b.id == me && a.id != me { return false }
|
#if os(iOS)
|
||||||
|
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
|
||||||
|
let isTeleportedID: (String) -> Bool = { id in
|
||||||
|
if teleportedSet.contains(id.lowercased()) { return true }
|
||||||
|
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return a.lastSeen > b.lastSeen
|
#else
|
||||||
}
|
let isTeleportedID: (String) -> Bool = { _ in false }
|
||||||
ForEach(ordered) { person in
|
#endif
|
||||||
|
|
||||||
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
|
let nonTele = displayIDs.filter { !isTeleportedID($0) }
|
||||||
|
let tele = displayIDs.filter { isTeleportedID($0) }
|
||||||
|
let finalOrder: [String] = nonTele + tele
|
||||||
|
let firstID = finalOrder.first
|
||||||
|
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
|
||||||
|
let person = personByID[pid]!
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let convKey = "nostr_" + String(person.id.prefix(16))
|
let isMe = (person.id == myHex)
|
||||||
if viewModel.unreadPrivateMessages.contains(convKey) {
|
#if os(iOS)
|
||||||
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
|
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
||||||
} else {
|
#else
|
||||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
|
let teleported = false
|
||||||
}
|
#endif
|
||||||
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
|
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
|
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||||
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
|
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
|
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.fontWeight(person.id == myHex ? .bold : .regular)
|
.fontWeight(isMe ? .bold : .regular)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(rowColor)
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||||
|
Text(suffix)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(suffixColor)
|
||||||
|
}
|
||||||
|
if isMe {
|
||||||
|
Text(" (you)")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(rowColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let me = myHex, person.id != me {
|
||||||
|
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||||
|
Image(systemName: "nosign")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.help("Blocked in geochash")
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
.padding(.top, person.id == firstID ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if person.id != myHex {
|
if person.id != myHex {
|
||||||
@@ -53,10 +100,46 @@ struct GeohashPeopleList: View {
|
|||||||
onTapPerson()
|
onTapPerson()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contextMenu {
|
||||||
|
if let me = myHex, person.id == me {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
|
if blocked {
|
||||||
|
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
|
} else {
|
||||||
|
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Seed and update order outside result builder
|
||||||
|
.onAppear {
|
||||||
|
orderedIDs = currentIDs
|
||||||
|
}
|
||||||
|
.onChange(of: currentIDs) { ids in
|
||||||
|
var newOrder = orderedIDs
|
||||||
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
|
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||||
|
if newOrder != orderedIDs { orderedIDs = newOrder }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Helper to split a trailing #abcd suffix
|
||||||
|
#if os(iOS)
|
||||||
|
private func splitSuffix(from name: String) -> (String, String) {
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -1,434 +0,0 @@
|
|||||||
//
|
|
||||||
// LinkPreviewView.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
#if os(iOS)
|
|
||||||
import LinkPresentation
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Link Metadata Cache
|
|
||||||
|
|
||||||
/// Cache for link metadata to prevent repeated network requests
|
|
||||||
private class LinkMetadataCache {
|
|
||||||
static let shared = LinkMetadataCache()
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
private let cache = NSCache<NSURL, CachedMetadata>()
|
|
||||||
private let imageCache = NSCache<NSURL, UIImage>()
|
|
||||||
#endif
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.linkmetadata.cache", attributes: .concurrent)
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
#if os(iOS)
|
|
||||||
cache.countLimit = 100 // Keep metadata for up to 100 URLs
|
|
||||||
imageCache.countLimit = 50 // Keep images for up to 50 URLs
|
|
||||||
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB limit for images
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
class CachedMetadata {
|
|
||||||
let metadata: LPLinkMetadata?
|
|
||||||
let title: String?
|
|
||||||
let host: String?
|
|
||||||
let error: Error?
|
|
||||||
let timestamp: Date
|
|
||||||
|
|
||||||
init(metadata: LPLinkMetadata? = nil, title: String? = nil, host: String? = nil, error: Error? = nil) {
|
|
||||||
self.metadata = metadata
|
|
||||||
self.title = title
|
|
||||||
self.host = host
|
|
||||||
self.error = error
|
|
||||||
self.timestamp = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCachedMetadata(for url: URL) -> (metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?)? {
|
|
||||||
return queue.sync {
|
|
||||||
guard let cached = cache.object(forKey: url as NSURL) else { return nil }
|
|
||||||
|
|
||||||
// Check if cache is older than 24 hours
|
|
||||||
if Date().timeIntervalSince(cached.timestamp) > 86400 {
|
|
||||||
cache.removeObject(forKey: url as NSURL)
|
|
||||||
imageCache.removeObject(forKey: url as NSURL)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let image = imageCache.object(forKey: url as NSURL)
|
|
||||||
return (cached.metadata, cached.title, cached.host, image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheMetadata(_ metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?, for url: URL) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let cached = CachedMetadata(metadata: metadata, title: title, host: host)
|
|
||||||
self.cache.setObject(cached, forKey: url as NSURL)
|
|
||||||
|
|
||||||
if let image = image {
|
|
||||||
let cost = Int(image.size.width * image.size.height * 4) // Approximate memory usage
|
|
||||||
self.imageCache.setObject(image, forKey: url as NSURL, cost: cost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheError(_ error: Error, for url: URL) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let cached = CachedMetadata(error: error)
|
|
||||||
self.cache.setObject(cached, forKey: url as NSURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func clearCache() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
#if os(iOS)
|
|
||||||
self.cache.removeAllObjects()
|
|
||||||
self.imageCache.removeAllObjects()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Link Preview View
|
|
||||||
|
|
||||||
struct LinkPreviewView: View {
|
|
||||||
let url: URL
|
|
||||||
let title: String?
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var metadata: LPLinkMetadata?
|
|
||||||
@State private var cachedTitle: String?
|
|
||||||
@State private var cachedHost: String?
|
|
||||||
@State private var isLoading = false
|
|
||||||
#endif
|
|
||||||
private var textColor: Color {
|
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
|
||||||
}
|
|
||||||
|
|
||||||
private var borderColor: Color {
|
|
||||||
textColor.opacity(0.3)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// Always use our custom compact view for consistent appearance
|
|
||||||
compactLinkView
|
|
||||||
.onAppear {
|
|
||||||
loadFromCacheOrFetch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var previewImage: UIImage? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var compactLinkView: some View {
|
|
||||||
Button(action: {
|
|
||||||
#if os(iOS)
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
#else
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
#endif
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Preview image or icon
|
|
||||||
Group {
|
|
||||||
#if os(iOS)
|
|
||||||
if let image = previewImage {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(8)
|
|
||||||
} else {
|
|
||||||
// Favicon or default icon
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color.blue.opacity(0.1))
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "link")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color.blue.opacity(0.1))
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "link")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
// Title
|
|
||||||
#if os(iOS)
|
|
||||||
Text(cachedTitle ?? metadata?.title ?? title ?? url.host ?? "Link")
|
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
#else
|
|
||||||
Text(title ?? url.host ?? "Link")
|
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Host
|
|
||||||
#if os(iOS)
|
|
||||||
Text(cachedHost ?? url.host ?? url.absoluteString)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(textColor.opacity(0.6))
|
|
||||||
.lineLimit(1)
|
|
||||||
#else
|
|
||||||
Text(url.host ?? url.absoluteString)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(textColor.opacity(0.6))
|
|
||||||
.lineLimit(1)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(borderColor, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var simpleLinkView: some View {
|
|
||||||
Button(action: {
|
|
||||||
#if os(iOS)
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
#else
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
#endif
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Link icon
|
|
||||||
Image(systemName: "link.circle.fill")
|
|
||||||
.font(.system(size: 32))
|
|
||||||
.foregroundColor(Color.blue.opacity(0.8))
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
// Title
|
|
||||||
Text(title ?? url.host ?? "Link")
|
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
|
|
||||||
// URL
|
|
||||||
Text(url.absoluteString)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Arrow indicator
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(textColor.opacity(0.5))
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(borderColor, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromCacheOrFetch() {
|
|
||||||
#if os(iOS)
|
|
||||||
// Check if we already have data in state
|
|
||||||
guard metadata == nil && !isLoading else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if let cached = LinkMetadataCache.shared.getCachedMetadata(for: url) {
|
|
||||||
// print("🔗 LinkPreviewView: Using CACHED metadata for: \(url.absoluteString)")
|
|
||||||
self.metadata = cached.metadata
|
|
||||||
self.cachedTitle = cached.title ?? cached.metadata?.title
|
|
||||||
self.cachedHost = cached.host ?? url.host
|
|
||||||
self.previewImage = cached.image
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not in cache, fetch it
|
|
||||||
// print("🔗 LinkPreviewView: FETCHING metadata for: \(url.absoluteString)")
|
|
||||||
isLoading = true
|
|
||||||
|
|
||||||
let provider = LPMetadataProvider()
|
|
||||||
provider.startFetchingMetadata(for: url) { fetchedMetadata, error in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isLoading = false
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
// Check if it's an ATS error for subresources (non-critical)
|
|
||||||
let errorString = error.localizedDescription.lowercased()
|
|
||||||
let isATSError = errorString.contains("app transport security") ||
|
|
||||||
errorString.contains("secure connection")
|
|
||||||
|
|
||||||
if !isATSError {
|
|
||||||
// Only log non-ATS errors
|
|
||||||
// print("🔗 LinkPreviewView: Error fetching metadata: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still try to show basic preview with URL info
|
|
||||||
self.cachedTitle = self.title ?? self.url.host
|
|
||||||
self.cachedHost = self.url.host
|
|
||||||
|
|
||||||
// Cache even failed attempts to avoid repeated fetches
|
|
||||||
LinkMetadataCache.shared.cacheMetadata(
|
|
||||||
nil,
|
|
||||||
title: self.cachedTitle,
|
|
||||||
host: self.cachedHost,
|
|
||||||
image: nil,
|
|
||||||
for: self.url
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fetchedMetadata = fetchedMetadata {
|
|
||||||
// Use the fetched metadata, or create new with our title
|
|
||||||
if let title = self.title, !title.isEmpty {
|
|
||||||
fetchedMetadata.title = title
|
|
||||||
}
|
|
||||||
self.metadata = fetchedMetadata
|
|
||||||
self.cachedTitle = fetchedMetadata.title ?? self.title
|
|
||||||
self.cachedHost = self.url.host
|
|
||||||
|
|
||||||
// Try to extract image
|
|
||||||
if let imageProvider = fetchedMetadata.imageProvider {
|
|
||||||
imageProvider.loadObject(ofClass: UIImage.self) { image, error in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let image = image as? UIImage {
|
|
||||||
self.previewImage = image
|
|
||||||
// Cache everything including the image
|
|
||||||
LinkMetadataCache.shared.cacheMetadata(
|
|
||||||
fetchedMetadata,
|
|
||||||
title: self.cachedTitle,
|
|
||||||
host: self.cachedHost,
|
|
||||||
image: image,
|
|
||||||
for: self.url
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Cache without image
|
|
||||||
LinkMetadataCache.shared.cacheMetadata(
|
|
||||||
fetchedMetadata,
|
|
||||||
title: self.cachedTitle,
|
|
||||||
host: self.cachedHost,
|
|
||||||
image: nil,
|
|
||||||
for: self.url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No image, cache what we have
|
|
||||||
LinkMetadataCache.shared.cacheMetadata(
|
|
||||||
fetchedMetadata,
|
|
||||||
title: self.cachedTitle,
|
|
||||||
host: self.cachedHost,
|
|
||||||
image: nil,
|
|
||||||
for: self.url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
// UIViewRepresentable wrapper for LPLinkView
|
|
||||||
struct LinkPreview: UIViewRepresentable {
|
|
||||||
let metadata: LPLinkMetadata
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
|
||||||
let containerView = UIView()
|
|
||||||
containerView.backgroundColor = .clear
|
|
||||||
|
|
||||||
let linkView = LPLinkView(metadata: metadata)
|
|
||||||
linkView.isUserInteractionEnabled = false // We handle taps at the SwiftUI level
|
|
||||||
linkView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
containerView.addSubview(linkView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
linkView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
||||||
linkView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
||||||
linkView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
|
||||||
linkView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
return containerView
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {
|
|
||||||
// Update if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Helper to extract URLs from text
|
|
||||||
extension String {
|
|
||||||
func extractURLs() -> [(url: URL, range: Range<String.Index>)] {
|
|
||||||
var urls: [(URL, Range<String.Index>)] = []
|
|
||||||
|
|
||||||
// Check for plain URLs
|
|
||||||
let types: NSTextCheckingResult.CheckingType = .link
|
|
||||||
if let detector = try? NSDataDetector(types: types.rawValue) {
|
|
||||||
let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
|
|
||||||
for match in matches {
|
|
||||||
if let range = Range(match.range, in: self),
|
|
||||||
let url = match.url {
|
|
||||||
urls.append((url, range))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
VStack {
|
|
||||||
LinkPreviewView(url: URL(string: "https://example.com")!, title: "Example Website")
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,7 @@ struct LocationChannelsSheet: View {
|
|||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
List {
|
||||||
// Mesh option first
|
// Mesh option first
|
||||||
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
|
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,14 @@ struct LocationChannelsSheet: View {
|
|||||||
// Nearby options
|
// Nearby options
|
||||||
if !manager.availableChannels.isEmpty {
|
if !manager.availableChannels.isEmpty {
|
||||||
ForEach(manager.availableChannels) { channel in
|
ForEach(manager.availableChannels) { channel in
|
||||||
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) {
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
|
let nameBase = locationName(for: channel.level)
|
||||||
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
|
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||||
|
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||||
|
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||||
|
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||||
|
manager.markTeleported(for: channel.geohash, false)
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
@@ -140,13 +147,22 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||||
let isValid = validateGeohash(normalized)
|
let isValid = validateGeohash(normalized)
|
||||||
Button("teleport") {
|
Button(action: {
|
||||||
let gh = normalized
|
let gh = normalized
|
||||||
guard isValid else { customError = "invalid geohash"; return }
|
guard isValid else { customError = "invalid geohash"; return }
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
|
// Mark this selection as a manual teleport
|
||||||
|
manager.markTeleported(for: ch.geohash, true)
|
||||||
manager.select(ChannelID.location(ch))
|
manager.select(ChannelID.location(ch))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("teleport")
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
Image(systemName: "face.dashed")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
@@ -198,7 +214,7 @@ struct LocationChannelsSheet: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -207,15 +223,28 @@ struct LocationChannelsSheet: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(parts.base)
|
Text(parts.base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
if let count = parts.countSuffix, !count.isEmpty {
|
if let count = parts.countSuffix, !count.isEmpty {
|
||||||
Text(count)
|
Text(count)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(subtitle)
|
HStack(spacing: 0) {
|
||||||
|
Text(subtitlePrefix)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
if let name = subtitleName {
|
||||||
|
Text(" • ")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
@@ -241,13 +270,17 @@ struct LocationChannelsSheet: View {
|
|||||||
// MARK: - Helpers for counts
|
// MARK: - Helpers for counts
|
||||||
private func meshTitleWithCount() -> String {
|
private func meshTitleWithCount() -> String {
|
||||||
// Count currently connected mesh peers (excluding self)
|
// Count currently connected mesh peers (excluding self)
|
||||||
|
let meshCount = meshCount()
|
||||||
|
let noun = meshCount == 1 ? "person" : "people"
|
||||||
|
return "mesh [\(meshCount) \(noun)]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func meshCount() -> Int {
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
let meshCount = 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 { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
let noun = meshCount == 1 ? "person" : "people"
|
|
||||||
return "#mesh [\(meshCount) \(noun)]"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||||
@@ -265,12 +298,12 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||||
switch len {
|
switch len {
|
||||||
case 0...2: return .country
|
case 0...2: return .region
|
||||||
case 3...4: return .region
|
case 3...4: return .province
|
||||||
case 5: return .city
|
case 5: return .city
|
||||||
case 6: return .neighborhood
|
case 6: return .neighborhood
|
||||||
case 7: return .block
|
case 7: return .block
|
||||||
default: return .street
|
default: return .block
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +313,81 @@ extension LocationChannelsSheet {
|
|||||||
private var standardGreen: Color {
|
private var standardGreen: Color {
|
||||||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
|
private var standardBlue: Color {
|
||||||
|
Color(red: 0.0, green: 0.478, blue: 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coverage helpers
|
||||||
|
extension LocationChannelsSheet {
|
||||||
|
private func coverageString(forPrecision len: Int) -> String {
|
||||||
|
// Approximate max cell dimension at equator for a given geohash length.
|
||||||
|
// Values sourced from common geohash dimension tables.
|
||||||
|
let maxMeters: Double = {
|
||||||
|
switch len {
|
||||||
|
case 2: return 1_250_000
|
||||||
|
case 3: return 156_000
|
||||||
|
case 4: return 39_100
|
||||||
|
case 5: return 4_890
|
||||||
|
case 6: return 1_220
|
||||||
|
case 7: return 153
|
||||||
|
case 8: return 38.2
|
||||||
|
case 9: return 4.77
|
||||||
|
case 10: return 1.19
|
||||||
|
default:
|
||||||
|
if len <= 1 { return 5_000_000 }
|
||||||
|
// For >10, scale down conservatively by ~1/4 each char
|
||||||
|
let over = len - 10
|
||||||
|
return 1.19 * pow(0.25, Double(over))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
let usesMetric: Bool = {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return Locale.current.measurementSystem == .metric
|
||||||
|
} else {
|
||||||
|
return Locale.current.usesMetricSystem
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if usesMetric {
|
||||||
|
let km = maxMeters / 1000.0
|
||||||
|
return "~\(formatDistance(km)) km"
|
||||||
|
} else {
|
||||||
|
let miles = maxMeters / 1609.344
|
||||||
|
return "~\(formatDistance(miles)) mi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDistance(_ value: Double) -> String {
|
||||||
|
if value >= 100 { return String(format: "%.0f", value.rounded()) }
|
||||||
|
if value >= 10 { return String(format: "%.1f", value) }
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bluetoothRangeString() -> String {
|
||||||
|
let usesMetric: Bool = {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
return Locale.current.measurementSystem == .metric
|
||||||
|
} else {
|
||||||
|
return Locale.current.usesMetricSystem
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
|
||||||
|
return usesMetric ? "~10–50 m" : "~30–160 ft"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
|
manager.locationNames[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
||||||
|
switch level {
|
||||||
|
case .region:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return "~"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ struct MeshPeerList: View {
|
|||||||
let onTapPeer: (String) -> Void
|
let onTapPeer: (String) -> Void
|
||||||
let onToggleFavorite: (String) -> Void
|
let onToggleFavorite: (String) -> Void
|
||||||
let onShowFingerprint: (String) -> Void
|
let onShowFingerprint: (String) -> Void
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if viewModel.allPeers.isEmpty {
|
if viewModel.allPeers.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
|
||||||
let myPeerID = viewModel.meshService.myPeerID
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||||
let isMe = peer.id == myPeerID
|
let isMe = peer.id == myPeerID
|
||||||
@@ -25,49 +28,52 @@ struct MeshPeerList: View {
|
|||||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
||||||
return (peer, isMe, hasUnread, enc)
|
return (peer, isMe, hasUnread, enc)
|
||||||
}
|
}
|
||||||
let peers = mapped.sorted { lhs, rhs in
|
// Stable visual order without mutating state here
|
||||||
let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false
|
let currentIDs = mapped.map { $0.peer.id }
|
||||||
let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
if lFav != rFav { return lFav }
|
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||||
let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname
|
mapped.first(where: { $0.peer.id == id })
|
||||||
let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname
|
|
||||||
return lhsName < rhsName
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<peers.count, id: \.self) { idx in
|
ForEach(0..<peers.count, id: \.self) { idx in
|
||||||
let item = peers[idx]
|
let item = peers[idx]
|
||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
let hasUnread = item.hasUnread
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||||
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
|
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
|
||||||
} else if hasUnread {
|
|
||||||
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
|
|
||||||
} else {
|
} else {
|
||||||
switch peer.connectionState {
|
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
|
||||||
case .bluetoothConnected:
|
|
||||||
Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 10)).foregroundColor(textColor)
|
|
||||||
case .nostrAvailable:
|
|
||||||
Image(systemName: "globe").font(.system(size: 10)).foregroundColor(.purple)
|
|
||||||
case .offline:
|
|
||||||
if peer.favoriteStatus?.isFavorite ?? false {
|
|
||||||
Image(systemName: "moon.fill").font(.system(size: 10)).foregroundColor(.gray)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "person").font(.system(size: 10)).foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
Text(displayName)
|
let (base, suffix) = splitSuffix(from: displayName)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
|
.foregroundColor(baseColor)
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||||
|
Text(suffix)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(suffixColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||||
|
Image(systemName: "nosign")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.help("Blocked")
|
||||||
|
}
|
||||||
|
|
||||||
if let icon = item.enc.icon, !isMe {
|
if let icon = item.enc.icon, !isMe {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red))
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -83,12 +89,36 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 6 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Seed and update order outside result builder
|
||||||
|
.onAppear {
|
||||||
|
let currentIDs = mapped.map { $0.peer.id }
|
||||||
|
orderedIDs = currentIDs
|
||||||
|
}
|
||||||
|
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
||||||
|
var newOrder = orderedIDs
|
||||||
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
|
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||||
|
if newOrder != orderedIDs { orderedIDs = newOrder }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to split a trailing #abcd suffix
|
||||||
|
private func splitSuffix(from name: String) -> (String, String) {
|
||||||
|
guard name.count >= 5 else { return (name, "") }
|
||||||
|
let suffix = String(name.suffix(5))
|
||||||
|
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
||||||
|
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
||||||
|
}) {
|
||||||
|
let base = String(name.dropLast(5))
|
||||||
|
return (base, suffix)
|
||||||
|
}
|
||||||
|
return (name, "")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// MessageTextHelpers.swift
|
||||||
|
// Shared text parsing helpers for message rendering.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private enum RegexCache {
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||||
|
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||||
|
var current = 0
|
||||||
|
for ch in self {
|
||||||
|
if ch.isWhitespace || ch.isNewline {
|
||||||
|
if current >= threshold { return true }
|
||||||
|
current = 0
|
||||||
|
} else {
|
||||||
|
current += 1
|
||||||
|
if current >= threshold { return true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
|
func extractCashuTokens(max: Int = 3) -> [String] {
|
||||||
|
let regex = RegexCache.cashu
|
||||||
|
let ns = self as NSString
|
||||||
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
|
var found: [String] = []
|
||||||
|
for m in regex.matches(in: self, options: [], range: range) {
|
||||||
|
if m.numberOfRanges > 0 {
|
||||||
|
let token = ns.substring(with: m.range(at: 0))
|
||||||
|
found.append(token)
|
||||||
|
if found.count >= max { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning:<payload>
|
||||||
|
func extractLightningLinks(max: Int = 3) -> [String] {
|
||||||
|
var results: [String] = []
|
||||||
|
let ns = self as NSString
|
||||||
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
|
// lightning: scheme
|
||||||
|
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
|
||||||
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
|
results.append(s)
|
||||||
|
if results.count >= max { return results }
|
||||||
|
}
|
||||||
|
// BOLT11
|
||||||
|
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
|
||||||
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
|
results.append("lightning:\(s)")
|
||||||
|
if results.count >= max { return results }
|
||||||
|
}
|
||||||
|
// LNURL bech32
|
||||||
|
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
|
||||||
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
|
results.append("lightning:\(s)")
|
||||||
|
if results.count >= max { return results }
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
// Sanity: known coords (Statue of Liberty approx)
|
// Sanity: known coords (Statue of Liberty approx)
|
||||||
let lat = 40.6892
|
let lat = 40.6892
|
||||||
let lon = -74.0445
|
let lon = -74.0445
|
||||||
let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision)
|
|
||||||
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
|
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
|
||||||
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
|
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
|
||||||
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
|
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
|
||||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
||||||
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
|
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
||||||
|
|
||||||
XCTAssertEqual(street.count, 8)
|
|
||||||
XCTAssertEqual(block.count, 7)
|
XCTAssertEqual(block.count, 7)
|
||||||
XCTAssertEqual(neighborhood.count, 6)
|
XCTAssertEqual(neighborhood.count, 6)
|
||||||
XCTAssertEqual(city.count, 5)
|
XCTAssertEqual(city.count, 5)
|
||||||
@@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(country.count, 2)
|
XCTAssertEqual(country.count, 2)
|
||||||
|
|
||||||
// All prefixes must match progressively
|
// All prefixes must match progressively
|
||||||
XCTAssertTrue(street.hasPrefix(block))
|
|
||||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||||
XCTAssertTrue(city.hasPrefix(region))
|
XCTAssertTrue(city.hasPrefix(region))
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
Relay URL,Latitude,Longitude
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nostr.slothy.win,37.7621,-122.3971
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
relay.nostr.com.au,37.7621,-122.3971
|
||||||
|
knostr.neutrine.com,48.8534,2.3488
|
||||||
|
nostr.nodeofsven.com,47.4875,8.2965
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
nostr-verif.slothy.win,37.7621,-122.3971
|
||||||
|
relay.lexingtonbitcoin.org,37.7621,-122.3971
|
||||||
|
nostr-1.nbo.angani.co,-1.2615,36.7903
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.easydns.ca,43.7064,-79.3986
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
relay.nostromo.social,49.4542,11.0775
|
||||||
|
offchain.pub,34.0522,-118.2437
|
||||||
|
relay.nostr.wirednet.jp,35.9356,139.3044
|
||||||
|
relay.nostrcheck.me,37.7621,-122.3971
|
||||||
|
nostrue.com,40.8043,-74.0121
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
nproxy.kristapsk.lv,60.1695,24.9354
|
||||||
|
nostr.spaceshell.xyz,37.7621,-122.3971
|
||||||
|
nostr-dev.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr-verified.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.roundrockbitcoiners.com,40.8043,-74.0121
|
||||||
|
slick.mjex.me,39.0437,-77.4875
|
||||||
|
nostr.yael.at,52.3740,4.8897
|
||||||
|
relay.primal.net,37.7621,-122.3971
|
||||||
|
nostr.oxtr.dev,50.4779,12.3713
|
||||||
|
nostr.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.liberty.fans,38.8003,-90.6265
|
||||||
|
nostr-02.dorafactory.org,1.2897,103.8501
|
||||||
|
relay.hodl.ar,-32.9468,-60.6393
|
||||||
|
nostr.middling.mydns.jp,35.8089,140.1185
|
||||||
|
nostr.namek.link,37.7621,-122.3971
|
||||||
|
nostrja-kari.heguro.com,37.7621,-122.3971
|
||||||
|
nostr.hifish.org,47.3667,8.5500
|
||||||
|
nostr.rikmeijer.nl,50.4779,12.3713
|
||||||
|
black.nostrcity.club,41.8119,-87.6873
|
||||||
|
nostr.hekster.org,37.3924,-121.9623
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
nostr.sagaciousd.com,49.2497,-123.1193
|
||||||
|
nostr.fbxl.net,43.7064,-79.3986
|
||||||
|
ithurtswhenip.ee,50.7990,-1.0913
|
||||||
|
relay2.nostrchat.io,49.4542,11.0775
|
||||||
|
relay1.nostrchat.io,60.1695,24.9354
|
||||||
|
nostr-01.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr.sathoarder.com,48.5839,7.7455
|
||||||
|
nostr.overmind.lol,37.7621,-122.3971
|
||||||
|
relay.verified-nostr.com,37.7621,-122.3971
|
||||||
|
purplerelay.com,50.1155,8.6842
|
||||||
|
relay.orangepill.ovh,49.0127,1.9694
|
||||||
|
nostr-relay.psfoundation.info,39.0437,-77.4875
|
||||||
|
soloco.nl,37.7621,-122.3971
|
||||||
|
relay.froth.zone,60.1695,24.9354
|
||||||
|
nostr.stakey.net,52.5250,5.7181
|
||||||
|
nostr.2b9t.xyz,34.0522,-118.2437
|
||||||
|
pyramid.fiatjaf.com,50.1155,8.6842
|
||||||
|
a.nos.lol,50.4779,12.3713
|
||||||
|
relay.magiccity.live,25.8130,-80.2320
|
||||||
|
nostr.notribe.net,40.8344,-74.1377
|
||||||
|
freelay.sovbit.host,64.1355,-21.8954
|
||||||
|
relay.credenso.cafe,43.4254,-80.5112
|
||||||
|
nostr.huszonegy.world,47.4984,19.0404
|
||||||
|
multiplexer.huszonegy.world,47.4984,19.0404
|
||||||
|
bucket.coracle.social,37.7621,-122.3971
|
||||||
|
nostr.kungfu-g.rip,33.7865,-84.4454
|
||||||
|
relay.artx.market,43.7064,-79.3986
|
||||||
|
relay.notoshi.win,13.3622,100.9835
|
||||||
|
vitor.nostr1.com,40.7143,-74.0060
|
||||||
|
nostr-02.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr-03.dorafactory.org,1.2897,103.8501
|
||||||
|
n.ok0.org,-36.8485,174.7635
|
||||||
|
nostr.0x7e.xyz,47.5056,8.7241
|
||||||
|
relay.nostr.net,50.4779,12.3713
|
||||||
|
strfry.openhoofd.nl,51.5717,3.7042
|
||||||
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
|
relay.usefusion.ai,38.7135,-78.1594
|
||||||
|
relay.varke.eu,52.6958,6.1944
|
||||||
|
nostr.satstralia.com,64.1355,-21.8954
|
||||||
|
relay.13room.space,37.7621,-122.3971
|
||||||
|
nostr.myshosholoza.co.za,52.3710,4.9042
|
||||||
|
nostr.carroarmato0.be,50.8517,3.6089
|
||||||
|
nostr.dbtc.link,37.7621,-122.3971
|
||||||
|
orangepiller.org,60.1695,24.9354
|
||||||
|
adre.su,59.9386,30.3141
|
||||||
|
relay.sincensura.org,37.7621,-122.3971
|
||||||
|
relay.freeplace.nl,52.3740,4.8897
|
||||||
|
bostr.bitcointxoko.com,64.1355,-21.8954
|
||||||
|
nostr.plantroon.com,50.1025,8.6299
|
||||||
|
srtrelay.c-stellar.net,37.7621,-122.3971
|
||||||
|
nostr.jfischer.org,49.4453,11.0222
|
||||||
|
nostr.novacisko.cz,52.2298,21.0118
|
||||||
|
relay.lumina.rocks,49.4453,11.0222
|
||||||
|
nostr.tavux.tech,50.9519,1.8563
|
||||||
|
relay.nostrhub.fr,50.1155,8.6842
|
||||||
|
relay.agorist.space,52.3740,4.8897
|
||||||
|
chorus.pjv.me,45.5229,-122.9898
|
||||||
|
relay.cosmicbolt.net,37.3924,-121.9623
|
||||||
|
santo.iguanatech.net,40.8344,-74.1377
|
||||||
|
relay.tagayasu.xyz,45.4112,-75.6981
|
||||||
|
relay.mostro.network,40.8344,-74.1377
|
||||||
|
relay.zone667.com,60.1695,24.9354
|
||||||
|
relay5.bitransfer.org,37.7621,-122.3971
|
||||||
|
relay.illuminodes.com,47.6062,-122.3321
|
||||||
|
relay2.angor.io,50.1155,8.6842
|
||||||
|
relay.satsdays.com,1.2897,103.8501
|
||||||
|
relay.angor.io,50.1155,8.6842
|
||||||
|
orangesync.tech,50.9333,6.9500
|
||||||
|
nostr-relay.cbrx.io,37.7621,-122.3971
|
||||||
|
relay.21e6.cz,50.0880,14.4208
|
||||||
|
nostr.chaima.info,50.1155,8.6842
|
||||||
|
relay.satlantis.io,32.8546,-79.9748
|
||||||
|
relay.digitalezukunft.cyou,45.5088,-73.5878
|
||||||
|
relay.tapestry.ninja,40.8043,-74.0121
|
||||||
|
relay.minibolt.info,37.7621,-122.3971
|
||||||
|
nostr.bilthon.dev,25.8130,-80.2320
|
||||||
|
nostr.makibisskey.work,37.7621,-122.3971
|
||||||
|
relay.mattybs.lol,37.7621,-122.3971
|
||||||
|
noxir.kpherox.dev,34.8436,135.5084
|
||||||
|
sendit.nosflare.com,37.7621,-122.3971
|
||||||
|
relay.coinos.io,37.7621,-122.3971
|
||||||
|
relay.nostraddress.com,37.7621,-122.3971
|
||||||
|
wot.nostr.party,36.1659,-86.7844
|
||||||
|
nostrelites.org,41.8500,-87.6500
|
||||||
|
relay.nostriot.com,43.7064,-79.3986
|
||||||
|
prl.plus,55.7522,37.6156
|
||||||
|
zap.watch,45.5088,-73.5878
|
||||||
|
wot.codingarena.top,50.4779,12.3713
|
||||||
|
nostr.azzamo.net,52.2284,21.0522
|
||||||
|
wot.sudocarlos.com,43.7064,-79.3986
|
||||||
|
relay.lnfi.network,45.6241,8.7851
|
||||||
|
wot.nostr.net,37.7621,-122.3971
|
||||||
|
relay.nostrdice.com,-33.8678,151.2073
|
||||||
|
wot.sebastix.social,51.3700,6.1681
|
||||||
|
wheat.happytavern.co,37.7621,-122.3971
|
||||||
|
relay.sigit.io,50.4779,12.3713
|
||||||
|
strfry.bonsai.com,37.8716,-122.2728
|
||||||
|
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
|
||||||
|
satsage.xyz,37.3924,-121.9623
|
||||||
|
relay.degmods.com,50.4779,12.3713
|
||||||
|
nostr.community.ath.cx,45.5088,-73.5878
|
||||||
|
nostr.coincrowd.fund,39.0437,-77.4875
|
||||||
|
strfry.shock.network,41.8847,-88.2040
|
||||||
|
cyberspace.nostr1.com,40.7143,-74.0060
|
||||||
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
|
||||||
|
relay.davidebtc.me,50.1155,8.6842
|
||||||
|
wot.dtonon.com,37.7621,-122.3971
|
||||||
|
relay.goodmorningbitcoin.com,37.7621,-122.3971
|
||||||
|
articles.layer3.news,37.3394,-121.8950
|
||||||
|
bostr.syobon.net,37.7621,-122.3971
|
||||||
|
nostr.agentcampfire.com,52.3740,4.8897
|
||||||
|
nostr.thebiglake.org,32.7244,-96.6755
|
||||||
|
schnorr.me,37.7621,-122.3971
|
||||||
|
relay.wolfcoil.com,35.6090,139.7302
|
||||||
|
nostr.camalolo.com,24.1469,120.6839
|
||||||
|
nostr.tac.lol,47.4740,-122.2610
|
||||||
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
|
relay.bitcoinveneto.org,64.1355,-21.8954
|
||||||
|
nostr.red5d.dev,37.7621,-122.3971
|
||||||
|
relay-testnet.k8s.layer3.news,37.3394,-121.8950
|
||||||
|
promenade.fiatjaf.com,50.1155,8.6842
|
||||||
|
nostrelay.memory-art.xyz,37.7621,-122.3971
|
||||||
|
inbox.azzamo.net,52.2284,21.0522
|
||||||
|
social.proxymana.net,60.1695,24.9354
|
||||||
|
relay.netstr.io,53.3331,-6.2489
|
||||||
|
premium.primal.net,37.7621,-122.3971
|
||||||
|
nostr.lojong.info,37.7621,-122.3971
|
||||||
|
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
|
||||||
|
relay.stream.labs.h3.se,59.3294,18.0687
|
||||||
|
tollbooth.stens.dev,51.4566,7.0123
|
||||||
|
relay.chakany.systems,37.7621,-122.3971
|
||||||
|
relay.mwaters.net,50.9519,1.8563
|
||||||
|
nostr-relay.shirogaku.xyz,37.7621,-122.3971
|
||||||
|
kitchen.zap.cooking,37.7621,-122.3971
|
||||||
|
relay.arx-ccn.com,50.4779,12.3713
|
||||||
|
relay.fr13nd5.com,50.1155,8.6842
|
||||||
|
nostr.tegila.com.br,39.0437,-77.4875
|
||||||
|
relay.jeffg.fyi,43.7064,-79.3986
|
||||||
|
relay.bullishbounty.com,37.7621,-122.3971
|
||||||
|
nostr.spicyz.io,37.7621,-122.3971
|
||||||
|
relay04.lnfi.network,39.0997,-94.5786
|
||||||
|
vidono.apps.slidestr.net,48.8534,2.3488
|
||||||
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
|
communities.nos.social,40.8344,-74.1377
|
||||||
|
relay.evanverma.com,40.8344,-74.1377
|
||||||
|
nostrelay.circum.space,51.4566,7.0123
|
||||||
|
wot.brightbolt.net,47.6928,-116.7850
|
||||||
|
relayrs.notoshi.win,37.7621,-122.3971
|
||||||
|
fenrir-s.notoshi.win,37.7621,-122.3971
|
||||||
|
relay.nsnip.io,60.1695,24.9354
|
||||||
|
x.kojira.io,37.7621,-122.3971
|
||||||
|
relay.hasenpfeffr.com,39.0437,-77.4875
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.rtvslawenia.com,49.4542,11.0775
|
||||||
|
relay.g1sms.fr,43.9298,2.1480
|
||||||
|
nostr.kalf.org,52.3740,4.8897
|
||||||
|
nostr.rblb.it,37.7621,-122.3971
|
||||||
|
nostr.4rs.nl,49.4453,11.0222
|
||||||
|
relay.vrtmrz.net,37.7621,-122.3971
|
||||||
|
nostr.hoppe-relay.it.com,45.5946,-121.1787
|
||||||
|
relay-rpi.edufeed.org,49.4453,11.0222
|
||||||
|
relay.copylaradio.com,50.7990,-1.0913
|
||||||
|
relay.ru.ac.th,13.7540,100.5014
|
||||||
|
relay.bitcoinartclock.com,50.4779,12.3713
|
||||||
|
wot.downisontheup.ca,47.6062,-122.3321
|
||||||
|
nostr.coincards.com,43.7064,-79.3986
|
||||||
|
relay.etch.social,41.2619,-95.8608
|
||||||
|
relay.mess.ch,47.1345,9.0964
|
||||||
|
relay.holzeis.me,37.7621,-122.3971
|
||||||
|
relay-admin.thaliyal.com,40.8220,-74.4488
|
||||||
|
nostr.thaliyal.com,40.8220,-74.4488
|
||||||
|
strfry.felixzieger.de,50.1025,8.6299
|
||||||
|
nostr.smut.cloud,37.7621,-122.3971
|
||||||
|
r.bitcoinhold.net,37.7621,-122.3971
|
||||||
|
nostr.blankfors.se,60.1695,24.9354
|
||||||
|
portal-relay.pareto.space,49.4453,11.0222
|
||||||
|
relay.getsafebox.app,43.7064,-79.3986
|
||||||
|
relay.anzenkodo.workers.dev,37.7621,-122.3971
|
||||||
|
relay.nostrhub.tech,49.4453,11.0222
|
||||||
|
nostr.prl.plus,52.3740,4.8897
|
||||||
|
nostr-2.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.zenon.network,40.7143,-74.0060
|
||||||
|
nostr-relay.amethyst.name,35.7721,-78.6386
|
||||||
|
relayone.geektank.ai,17.1210,-61.8433
|
||||||
|
fanfares.nostr1.com,40.7143,-74.0060
|
||||||
|
wot.geektank.ai,17.1210,-61.8433
|
||||||
|
relay-dev.satlantis.io,40.8344,-74.1377
|
||||||
|
relay.siamdev.cc,13.9178,100.4240
|
||||||
|
relay.nosto.re,51.3700,6.1681
|
||||||
|
wot.soundhsa.com,39.0997,-94.5786
|
||||||
|
nostr.n7ekb.net,47.5707,-122.2221
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
relay.puresignal.news,37.7621,-122.3971
|
||||||
|
relay.nostx.io,37.7621,-122.3971
|
||||||
|
nostr.now,35.6090,139.7302
|
||||||
|
relay.artiostr.ch,37.7621,-122.3971
|
||||||
|
relay.oldenburg.cool,50.1155,8.6842
|
||||||
|
theoutpost.life,64.1355,-21.8954
|
||||||
|
khatru.nostrver.se,51.3700,6.1681
|
||||||
|
relay.wavefunc.live,37.7915,-122.4018
|
||||||
|
nostr-relay.zimage.com,34.0522,-118.2437
|
||||||
|
relay.javi.space,43.4628,11.8807
|
||||||
|
bostr.shop,42.8865,-78.8784
|
||||||
|
relay.letsfo.com,52.2298,21.0118
|
||||||
|
alien.macneilmediagroup.com,37.7621,-122.3971
|
||||||
|
rn1.sotiras.org,37.7621,-122.3971
|
||||||
|
gnostr.com,42.6975,23.3241
|
||||||
|
relay.conduit.market,37.7621,-122.3971
|
||||||
|
relay.hivetalk.org,37.3924,-121.9623
|
||||||
|
nostr.l484.com,30.2960,-97.6396
|
||||||
|
relay.chorus.community,50.1155,8.6842
|
||||||
|
nostr-relay.moe.gift,37.7621,-122.3971
|
||||||
|
relay.nostrcal.com,37.7621,-122.3971
|
||||||
|
temp.iris.to,37.7621,-122.3971
|
||||||
|
librerelay.aaroniumii.com,37.7621,-122.3971
|
||||||
|
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
|
||||||
|
relay.barine.co,37.7621,-122.3971
|
||||||
|
nostr.rohoss.com,48.1374,11.5755
|
||||||
|
wot.nostr.place,30.2672,-97.7431
|
||||||
|
relay.utxo.farm,34.7331,135.8183
|
||||||
|
relay.bankless.at,37.7621,-122.3971
|
||||||
|
relay.toastr.net,40.8043,-74.0121
|
||||||
|
nostr.excentered.com,52.5244,13.4105
|
||||||
|
relay.mccormick.cx,52.3740,4.8897
|
||||||
|
relay.cypherflow.ai,48.8534,2.3488
|
||||||
|
relay.laantungir.net,45.3134,-73.8725
|
||||||
|
nostr.veladan.dev,37.7621,-122.3971
|
||||||
|
nostr.tadryanom.me,37.7621,-122.3971
|
||||||
|
nostr-relay.online,37.7621,-122.3971
|
||||||
|
nostr.night7.space,50.4779,12.3713
|
||||||
|
dev-nostr.bityacht.io,25.0531,121.5264
|
||||||
|
Reference in New Issue
Block a user