mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e5043f875 | ||
|
|
11cd5527ed | ||
|
|
cf1bfdac6b | ||
|
|
b63a595b04 | ||
|
|
d7b7f1f673 | ||
|
|
7aa3622349 | ||
|
|
96c6fc0c0d | ||
|
|
a7d5b2d7d9 | ||
|
|
680a390b2d | ||
|
|
60b0deee7b | ||
|
|
2f7c0aaaf7 | ||
|
|
7c4c3f1391 | ||
|
|
3c06bd6386 | ||
|
|
acc11101ad | ||
|
|
5fd9140ffa | ||
|
|
e6903456ab | ||
|
|
85f99984c4 | ||
|
|
75c8933091 | ||
|
|
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 |
@@ -71,3 +71,7 @@ __pycache__/
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
TestResult.xcresult/
|
||||
*.xcresult/
|
||||
build.log
|
||||
*.log
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
@@ -11,50 +11,99 @@ A decentralized peer-to-peer messaging app that works over Bluetooth mesh networ
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||
|
||||
|
||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
|
||||
- **Transport**: Bluetooth Low Energy mesh network
|
||||
- **Scope**: Local devices within multi-hop range
|
||||
- **Internet**: Not required
|
||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
||||
|
||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
||||
|
||||
- **Transport**: Nostr protocol over internet
|
||||
- **Scope**: Geographic areas defined by geohash precision
|
||||
- `block` (7 chars): City block level
|
||||
- `neighborhood` (6 chars): District/neighborhood
|
||||
- `city` (5 chars): City level
|
||||
- `province` (4 chars): State/province
|
||||
- `region` (2 chars): Country/large region
|
||||
- **Internet**: Required (connects to Nostr relays)
|
||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
||||
|
||||
### Direct Message Routing
|
||||
|
||||
Private messages use **intelligent transport selection**:
|
||||
|
||||
1. **Bluetooth First** (preferred when available)
|
||||
|
||||
- Direct connection with established Noise session
|
||||
- Fastest and most private option
|
||||
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
- Messages queued until transport becomes available
|
||||
- Automatic delivery when connection established
|
||||
|
||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
@@ -68,6 +117,7 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
### Option 2: Using Swift Package Manager
|
||||
|
||||
1. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ While the Noise handshake cryptographically authenticates a peer's key, it doesn
|
||||
### 4.2. Favorites and Blocking
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
|
||||
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||
@@ -56,11 +63,12 @@
|
||||
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
||||
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
|
||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
||||
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 */; };
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
|
||||
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
|
||||
@@ -82,7 +90,6 @@
|
||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
|
||||
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.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 */; };
|
||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
||||
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; };
|
||||
@@ -104,9 +111,17 @@
|
||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
|
||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
|
||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||
@@ -126,6 +141,10 @@
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.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 */; };
|
||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
||||
@@ -189,6 +208,9 @@
|
||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
|
||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||
@@ -204,6 +226,7 @@
|
||||
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
||||
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
||||
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
|
||||
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
|
||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
||||
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
||||
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
||||
@@ -233,9 +256,12 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
||||
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -243,6 +269,8 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -302,6 +330,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2F82C5FC8433F4064F079D1F /* bitchat */,
|
||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
@@ -321,8 +350,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
||||
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
||||
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
||||
EA706D8E5097785414646A8E /* Info.plist */,
|
||||
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
||||
@@ -387,6 +416,7 @@
|
||||
9A78348821A7D3374607D4E3 /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
|
||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
|
||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
|
||||
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
|
||||
@@ -421,13 +451,14 @@
|
||||
A55126E93155456CAA8D6656 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
|
||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */,
|
||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -458,6 +489,7 @@
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
@@ -494,6 +526,9 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
||||
@@ -515,10 +550,12 @@
|
||||
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
|
||||
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
|
||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
||||
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
|
||||
);
|
||||
path = Nostr;
|
||||
sourceTree = "<group>";
|
||||
@@ -683,6 +720,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -692,6 +730,7 @@
|
||||
files = (
|
||||
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -702,6 +741,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
|
||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -710,6 +750,11 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
||||
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
@@ -729,12 +774,12 @@
|
||||
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
|
||||
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||
@@ -756,6 +801,8 @@
|
||||
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -763,6 +810,11 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
||||
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
||||
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
@@ -782,12 +834,12 @@
|
||||
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
|
||||
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||
@@ -809,6 +861,8 @@
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
|
||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -822,6 +876,7 @@
|
||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -844,6 +899,7 @@
|
||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -960,7 +1016,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -991,7 +1047,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1046,7 +1102,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1078,7 +1134,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1167,7 +1223,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1260,7 +1316,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
+32
-18
@@ -21,6 +21,8 @@ struct BitchatApp: App {
|
||||
|
||||
init() {
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
// Warm up georelay directory and refresh if stale (once/day)
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -29,6 +31,13 @@ struct BitchatApp: App {
|
||||
.environmentObject(chatViewModel)
|
||||
.onAppear {
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
@@ -44,8 +53,8 @@ struct BitchatApp: App {
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Send leave message when going to background
|
||||
chatViewModel.meshService.stopServices()
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
break
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
@@ -90,30 +99,18 @@ struct BitchatApp: App {
|
||||
return
|
||||
}
|
||||
|
||||
// Only process if shared within last 30 seconds
|
||||
if Date().timeIntervalSince(sharedDate) < 30 {
|
||||
// Only process if shared within configured window
|
||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||
|
||||
// Clear the shared content
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
userDefaults.synchronize()
|
||||
// No need to force synchronize here
|
||||
|
||||
// Show notification about shared content
|
||||
// Send the shared content immediately on the main queue
|
||||
DispatchQueue.main.async {
|
||||
// Add system message about sharing
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "preparing to share \(contentType)...",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
self.chatViewModel.messages.append(systemMessage)
|
||||
}
|
||||
|
||||
// Send the shared content after a short delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
@@ -176,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle deeplink (e.g., geohash activity)
|
||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||
#else
|
||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
@@ -195,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
|
||||
@@ -106,6 +106,8 @@ enum HandshakeState {
|
||||
struct CryptographicIdentity: Codable {
|
||||
let fingerprint: String // SHA256 of public key
|
||||
let publicKey: Data // Noise static public key
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
@@ -149,6 +151,9 @@ struct IdentityCache: Codable {
|
||||
// Last interaction timestamps (privacy: optional)
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
}
|
||||
@@ -170,56 +175,7 @@ struct PendingActions {
|
||||
var setPetname: String?
|
||||
}
|
||||
|
||||
// MARK: - Privacy Settings
|
||||
|
||||
struct PrivacySettings: Codable {
|
||||
// Level 1: Maximum privacy (default)
|
||||
var persistIdentityCache = false
|
||||
var showLastSeen = false
|
||||
|
||||
// Level 2: Convenience
|
||||
var autoAcceptKnownFingerprints = false
|
||||
var rememberNicknameHistory = false
|
||||
|
||||
// Level 3: Social
|
||||
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
|
||||
}
|
||||
|
||||
// MARK: - Conflict Resolution
|
||||
|
||||
/// Strategies for resolving identity conflicts in the decentralized network.
|
||||
/// Handles cases where multiple peers claim the same nickname or when
|
||||
/// identity mappings become ambiguous due to network partitions.
|
||||
enum ConflictResolution {
|
||||
case acceptNew(petname: String) // "John (2)"
|
||||
case rejectNew
|
||||
case blockFingerprint(String)
|
||||
case alertUser(message: String)
|
||||
}
|
||||
|
||||
// MARK: - UI State
|
||||
|
||||
struct PeerUIState {
|
||||
let peerID: String
|
||||
let nickname: String
|
||||
var identityState: IdentityState
|
||||
var connectionQuality: ConnectionQuality
|
||||
|
||||
enum IdentityState {
|
||||
case unknown // Gray - No identity info
|
||||
case unverifiedKnown(String) // Blue - Handshake done, matches cache
|
||||
case verified(String) // Green - Cryptographically verified
|
||||
case conflict(String, String) // Red - Nickname doesn't match fingerprint
|
||||
case pending // Yellow - Handshake in progress
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionQuality {
|
||||
case excellent
|
||||
case good
|
||||
case poor
|
||||
case disconnected
|
||||
}
|
||||
//
|
||||
|
||||
// MARK: - Migration Support
|
||||
// Removed LegacyFavorite - no longer needed
|
||||
//
|
||||
|
||||
@@ -230,6 +230,91 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
let updated = CryptographicIdentity(
|
||||
fingerprint: existing.fingerprint,
|
||||
publicKey: existing.publicKey,
|
||||
signingPublicKey: existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
// New entry
|
||||
let entry = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
if let claimed = claimedNickname {
|
||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: claimed,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
// Update claimed nickname if changed
|
||||
if identity.claimedNickname != claimed {
|
||||
identity.claimedNickname = claimed
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
}
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve cryptographic identity by fingerprint
|
||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
||||
queue.sync { cryptographicIdentities[fingerprint] }
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
}
|
||||
}
|
||||
|
||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||
queue.sync {
|
||||
return Array(cache.socialIdentities.values)
|
||||
@@ -336,6 +421,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
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
// Favorite-related properties
|
||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
// Connection state
|
||||
enum ConnectionState {
|
||||
case bluetoothConnected
|
||||
case meshReachable // Seen via mesh recently, not directly connected
|
||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||
case offline // Not connected via any transport
|
||||
}
|
||||
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
var connectionState: ConnectionState {
|
||||
if isConnected {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
return .nostrAvailable
|
||||
@@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return "📻" // Radio icon for mesh connection
|
||||
case .meshReachable:
|
||||
return "📡" // Antenna for mesh reachable
|
||||
case .nostrAvailable:
|
||||
return "🌐" // Purple globe for Nostr
|
||||
case .offline:
|
||||
@@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
// Load favorite status - will be set later by the manager
|
||||
self.favoriteStatus = nil
|
||||
@@ -89,219 +97,4 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer Manager
|
||||
|
||||
/// Manages the collection of peers and their states
|
||||
@MainActor
|
||||
class PeerManager: ObservableObject {
|
||||
@Published var peers: [BitchatPeer] = []
|
||||
@Published var favorites: [BitchatPeer] = []
|
||||
@Published var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
private let meshService: Transport
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
init(meshService: Transport) {
|
||||
self.meshService = meshService
|
||||
updatePeers()
|
||||
|
||||
// Listen for updates
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleFavoriteChanged),
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleFavoriteChanged() {
|
||||
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
updatePeers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func updatePeers() {
|
||||
// Reduce log verbosity - only log when count changes
|
||||
let previousCount = peers.count
|
||||
|
||||
// Get current mesh peers
|
||||
let meshPeers = meshService.getPeerNicknames()
|
||||
|
||||
// Build peer list
|
||||
var allPeers: [BitchatPeer] = []
|
||||
var connectedNicknames: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Add connected mesh peers (only if actually connected or relay connected)
|
||||
for (peerID, nickname) in meshPeers {
|
||||
guard let noiseKey = Data(hexString: peerID) else { continue }
|
||||
|
||||
// Safety check: Never add our own peer ID
|
||||
if peerID == meshService.myPeerID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this peer is actually connected
|
||||
let isConnected = meshService.isPeerConnected(peerID)
|
||||
|
||||
// Skip disconnected peers unless they're favorites (handled later)
|
||||
if !isConnected {
|
||||
continue
|
||||
}
|
||||
|
||||
if isConnected {
|
||||
connectedNicknames.insert(nickname)
|
||||
}
|
||||
|
||||
// Track that we've added this peer ID
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: nickname,
|
||||
isConnected: isConnected
|
||||
)
|
||||
// Set favorite status - check both by current noise key and by nickname
|
||||
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
|
||||
peer.favoriteStatus = favoriteStatus
|
||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||
} else {
|
||||
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
|
||||
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
|
||||
if let favorite = favoriteByNickname {
|
||||
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
|
||||
category: SecureLogger.session, level: .info)
|
||||
// Update the favorite's noise key to match the current connection
|
||||
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
|
||||
// Get the updated favorite with the new key
|
||||
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
||||
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
||||
}
|
||||
}
|
||||
allPeers.append(peer)
|
||||
}
|
||||
|
||||
// Add offline favorites (only those not currently connected AND that we actively favorite)
|
||||
|
||||
for (favoriteKey, favorite) in favoritesService.favorites {
|
||||
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
|
||||
|
||||
// Skip if this peer is already connected (by nickname)
|
||||
if connectedNicknames.contains(favorite.peerNickname) {
|
||||
// Skipping favorite - already connected
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we already added a peer with this ID (prevents duplicates)
|
||||
if addedPeerIDs.contains(favoriteID) {
|
||||
// Skipping favorite - peer ID already added
|
||||
continue
|
||||
}
|
||||
|
||||
// Only add peers that WE favorite (not just ones who favorite us)
|
||||
if !favorite.isFavorite {
|
||||
// Skipping - we don't favorite them
|
||||
continue
|
||||
}
|
||||
|
||||
// Add this favorite as an offline peer
|
||||
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: favoriteID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
isConnected: false
|
||||
)
|
||||
// Set favorite status
|
||||
peer.favoriteStatus = favorite
|
||||
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
||||
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
|
||||
allPeers.append(peer)
|
||||
}
|
||||
|
||||
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
|
||||
allPeers = allPeers.filter { peer in
|
||||
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
|
||||
}
|
||||
|
||||
// Sort: Connected first, then favorites, then alphabetical
|
||||
allPeers.sort { lhs, rhs in
|
||||
// Direct connections first
|
||||
if lhs.isConnected != rhs.isConnected {
|
||||
return lhs.isConnected
|
||||
}
|
||||
// Then favorites
|
||||
if lhs.isFavorite != rhs.isFavorite {
|
||||
return lhs.isFavorite
|
||||
}
|
||||
// Finally alphabetical
|
||||
return lhs.displayName < rhs.displayName
|
||||
}
|
||||
|
||||
// Single pass to compute all subsets and counts
|
||||
var favorites: [BitchatPeer] = []
|
||||
var mutualFavorites: [BitchatPeer] = []
|
||||
var connectedCount = 0
|
||||
var offlineCount = 0
|
||||
|
||||
for peer in allPeers {
|
||||
if peer.isFavorite {
|
||||
favorites.append(peer)
|
||||
}
|
||||
if peer.isMutualFavorite {
|
||||
mutualFavorites.append(peer)
|
||||
}
|
||||
if peer.isConnected {
|
||||
connectedCount += 1
|
||||
} else {
|
||||
offlineCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Final safety check: ensure no duplicate IDs
|
||||
var finalPeers: [BitchatPeer] = []
|
||||
var seenIDs: Set<String> = []
|
||||
for peer in allPeers {
|
||||
if !seenIDs.contains(peer.id) {
|
||||
seenIDs.insert(peer.id)
|
||||
finalPeers.append(peer)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
self.peers = finalPeers
|
||||
self.favorites = favorites
|
||||
self.mutualFavorites = mutualFavorites
|
||||
|
||||
// Log peer list summary sparingly at debug level
|
||||
if favoritesService.favorites.count > 0 {
|
||||
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else if previousCount != allPeers.count {
|
||||
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFavorite(_ peer: BitchatPeer) {
|
||||
if peer.isFavorite {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
} else {
|
||||
favoritesService.addFavorite(
|
||||
peerNoisePublicKey: peer.noisePublicKey,
|
||||
peerNostrPublicKey: peer.nostrPublicKey,
|
||||
peerNickname: peer.nickname
|
||||
)
|
||||
}
|
||||
updatePeers()
|
||||
}
|
||||
}
|
||||
//
|
||||
|
||||
@@ -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 = TransportConfig.geoRelayFetchIntervalSeconds // 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
|
||||
struct KeychainHelper {
|
||||
static func save(key: String, data: Data, service: String) {
|
||||
let query: [String: Any] = [
|
||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
@@ -106,6 +109,8 @@ struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
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
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
@@ -159,14 +164,20 @@ struct NostrIdentityBridge {
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
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
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+103
-129
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||
@@ -12,8 +13,9 @@ struct NostrProtocol {
|
||||
enum EventKind: Int {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-17 gift wrap
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ struct NostrProtocol {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
@@ -103,12 +105,16 @@ struct NostrProtocol {
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname, !nickname.isEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
@@ -116,8 +122,8 @@ struct NostrProtocol {
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let signingKey = try senderIdentity.signingKey()
|
||||
return try event.sign(with: signingKey)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@@ -143,9 +149,8 @@ struct NostrProtocol {
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
||||
return try seal.sign(with: signingKey)
|
||||
// Sign the seal with the sender's Schnorr private key
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
private static func createGiftWrap(
|
||||
@@ -175,9 +180,8 @@ struct NostrProtocol {
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
||||
return try giftWrap.sign(with: signingKey)
|
||||
// Sign the gift wrap with the wrap Schnorr private key
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
}
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
@@ -223,7 +227,7 @@ struct NostrProtocol {
|
||||
return try NostrEvent(from: rumorDict)
|
||||
}
|
||||
|
||||
// MARK: - Encryption (NIP-44 style)
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
@@ -235,33 +239,31 @@ struct NostrProtocol {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// Derived shared secret
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
let nonce = AES.GCM.Nonce()
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
// Encrypt
|
||||
let sealed = try AES.GCM.seal(
|
||||
plaintext.data(using: .utf8)!,
|
||||
using: SymmetricKey(data: sharedSecret),
|
||||
nonce: nonce
|
||||
)
|
||||
|
||||
// Combine nonce + ciphertext + tag
|
||||
var result = Data()
|
||||
result.append(nonce.withUnsafeBytes { Data($0) })
|
||||
result.append(sealed.ciphertext)
|
||||
result.append(sealed.tag)
|
||||
|
||||
return result.base64EncodedString()
|
||||
// v2: base64url(nonce24 || ciphertext || tag)
|
||||
var combined = Data()
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
}
|
||||
|
||||
private static func decrypt(
|
||||
@@ -269,86 +271,45 @@ struct NostrProtocol {
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
|
||||
// Decrypting message
|
||||
|
||||
guard let data = Data(base64Encoded: ciphertext),
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
|
||||
// Ciphertext data parsed
|
||||
let nonce24 = data.prefix(24)
|
||||
let rest = data.dropFirst(24)
|
||||
let tag = rest.suffix(16)
|
||||
let ct = rest.dropLast(16)
|
||||
|
||||
// Extract components
|
||||
let nonceData = data.prefix(12)
|
||||
let ciphertextData = data.dropFirst(12).dropLast(16)
|
||||
let tagData = data.suffix(16)
|
||||
|
||||
// Components parsed
|
||||
|
||||
// Derive shared secret - try with default Y coordinate first
|
||||
var sharedSecret: Data
|
||||
var decrypted: Data? = nil
|
||||
|
||||
do {
|
||||
sharedSecret = try deriveSharedSecret(
|
||||
privateKey: recipientKey,
|
||||
publicKey: senderPubkeyData
|
||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
tag: Data(tag),
|
||||
key: key,
|
||||
nonce24: Data(nonce24)
|
||||
)
|
||||
// Derived shared secret with first Y coordinate
|
||||
}
|
||||
|
||||
// Try to decrypt
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: AES.GCM.Nonce(data: nonceData),
|
||||
ciphertext: ciphertextData,
|
||||
tag: tagData
|
||||
)
|
||||
|
||||
do {
|
||||
decrypted = try AES.GCM.open(
|
||||
sealedBox,
|
||||
using: SymmetricKey(data: sharedSecret)
|
||||
)
|
||||
// AES-GCM decryption successful
|
||||
} catch {
|
||||
// AES-GCM decryption failed, trying alternate
|
||||
|
||||
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
|
||||
if senderPubkeyData.count == 32 {
|
||||
// Trying alternate Y coordinate
|
||||
|
||||
// Force deriveSharedSecret to use odd Y by manipulating the data
|
||||
var altPubkey = Data()
|
||||
altPubkey.append(0x03) // Force odd Y
|
||||
altPubkey.append(senderPubkeyData)
|
||||
|
||||
sharedSecret = try deriveSharedSecretDirect(
|
||||
privateKey: recipientKey,
|
||||
publicKey: altPubkey
|
||||
)
|
||||
|
||||
decrypted = try AES.GCM.open(
|
||||
sealedBox,
|
||||
using: SymmetricKey(data: sharedSecret)
|
||||
)
|
||||
// AES-GCM decryption successful with alternate Y
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw error
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
guard let finalDecrypted = decrypted else {
|
||||
throw NostrError.encryptionFailed
|
||||
}
|
||||
|
||||
return String(data: finalDecrypted, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
@@ -408,17 +369,8 @@ struct NostrProtocol {
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// ECDH shared secret derived
|
||||
|
||||
// Derive key using HKDF for NIP-44 v2
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data(),
|
||||
outputByteCount: 32
|
||||
)
|
||||
|
||||
let result = derivedKey.withUnsafeBytes { Data($0) }
|
||||
// Final derived key ready
|
||||
return result
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
// Direct version that doesn't try to add prefixes
|
||||
@@ -448,15 +400,8 @@ struct NostrProtocol {
|
||||
// Convert SharedSecret to Data
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
|
||||
// Derive key using HKDF for NIP-44 v2
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data(),
|
||||
outputByteCount: 32
|
||||
)
|
||||
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
private static func randomizedTimestamp() -> Date {
|
||||
@@ -469,7 +414,7 @@ struct NostrProtocol {
|
||||
|
||||
// Log with explicit UTC and local time for debugging
|
||||
let formatter = DateFormatter()
|
||||
// Removed unnecessary date formatting operations
|
||||
//
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||
|
||||
@@ -525,16 +470,16 @@ struct NostrEvent: Codable {
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
// Convert to Schnorr key for Nostr signing
|
||||
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
|
||||
|
||||
// Sign with Schnorr
|
||||
// Sign with Schnorr (BIP-340)
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
|
||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
|
||||
@@ -577,3 +522,32 @@ enum NostrError: Error {
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func base64URLEncode(_ data: Data) -> String {
|
||||
return data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s
|
||||
let pad = (4 - (str.count % 4)) % 4
|
||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
|
||||
// Default relay list (can be customized)
|
||||
private static let defaultRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
@@ -47,10 +48,10 @@ class NostrRelayManager: ObservableObject {
|
||||
private let messageQueueLock = NSLock()
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
|
||||
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
|
||||
private let backoffMultiplier: Double = 2.0 // Double each time
|
||||
private let maxReconnectAttempts = 10 // Stop after 10 attempts
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
|
||||
// Reconnection timer
|
||||
private var reconnectionTimer: Timer?
|
||||
@@ -77,9 +78,23 @@ class NostrRelayManager: ObservableObject {
|
||||
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)
|
||||
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
|
||||
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(
|
||||
filter: NostrFilter,
|
||||
id: String = UUID().uuidString,
|
||||
relayUrls: [String]? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void
|
||||
) {
|
||||
messageHandlers[id] = handler
|
||||
@@ -119,8 +135,14 @@ class NostrRelayManager: ObservableObject {
|
||||
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
||||
// category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Send subscription to all connected relays
|
||||
for (relayUrl, connection) in connections {
|
||||
// Target specific relays if provided; else all 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
|
||||
if let error = error {
|
||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
||||
@@ -267,7 +289,7 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Only log non-gift-wrap events to reduce noise
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
|
||||
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
@@ -299,11 +321,11 @@ class NostrRelayManager: ObservableObject {
|
||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||
if success {
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
|
||||
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
|
||||
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
||||
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
||||
}
|
||||
}
|
||||
@@ -331,7 +353,7 @@ class NostrRelayManager: ObservableObject {
|
||||
let data = try encoder.encode(req)
|
||||
let message = String(data: data, encoding: .utf8) ?? ""
|
||||
|
||||
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)",
|
||||
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
connection.send(.string(message)) { [weak self] error in
|
||||
@@ -546,7 +568,7 @@ struct NostrFilter: Encodable {
|
||||
filter.kinds = [1059] // Gift wrap kind
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["p": [pubkey]]
|
||||
filter.limit = 100 // Add a reasonable limit
|
||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||
return filter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||
var out = Data(count: 12)
|
||||
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||
// key (8 words)
|
||||
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||
// nonce (4 words)
|
||||
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||
]
|
||||
|
||||
// 20 rounds (10 double rounds)
|
||||
for _ in 0..<10 {
|
||||
// Column rounds
|
||||
quarterRound(&state, 0, 4, 8, 12)
|
||||
quarterRound(&state, 1, 5, 9, 13)
|
||||
quarterRound(&state, 2, 6, 10, 14)
|
||||
quarterRound(&state, 3, 7, 11, 15)
|
||||
// Diagonal rounds
|
||||
quarterRound(&state, 0, 5, 10, 15)
|
||||
quarterRound(&state, 1, 6, 11, 12)
|
||||
quarterRound(&state, 2, 7, 8, 13)
|
||||
quarterRound(&state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
// Output subkey: state[0..3] and state[12..15]
|
||||
var out = Data(count: 32)
|
||||
out.storeLEWord(state[0], at: 0)
|
||||
out.storeLEWord(state[1], at: 4)
|
||||
out.storeLEWord(state[2], at: 8)
|
||||
out.storeLEWord(state[3], at: 12)
|
||||
out.storeLEWord(state[12], at: 16)
|
||||
out.storeLEWord(state[13], at: 20)
|
||||
out.storeLEWord(state[14], at: 24)
|
||||
out.storeLEWord(state[15], at: 28)
|
||||
return out
|
||||
}
|
||||
|
||||
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||
let range = offset..<(offset+4)
|
||||
let bytes = self[range]
|
||||
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||
let b = ptr.bindMemory(to: UInt8.self)
|
||||
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||
let bytes: [UInt8] = [
|
||||
UInt8(value & 0xff),
|
||||
UInt8((value >> 8) & 0xff),
|
||||
UInt8((value >> 16) & 0xff),
|
||||
UInt8((value >> 24) & 0xff)
|
||||
]
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,8 +181,7 @@ enum LazyHandshakeState {
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
// MARK: - Special Recipients (removed)
|
||||
// Previously defined broadcast identifiers were unused; removed for simplicity.
|
||||
//
|
||||
|
||||
// MARK: - Core Protocol Structures
|
||||
|
||||
@@ -263,8 +267,7 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delivery Acknowledgments (removed)
|
||||
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
|
||||
//
|
||||
|
||||
// MARK: - Read Receipts
|
||||
|
||||
@@ -356,7 +359,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
|
||||
// PeerIdentityBinding removed (unused).
|
||||
//
|
||||
|
||||
|
||||
// MARK: - Delivery Status
|
||||
@@ -410,12 +413,12 @@ class BitchatMessage: Codable {
|
||||
// Cached formatted text (not included in Codable)
|
||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||
|
||||
func getCachedFormattedText(isDark: Bool) -> AttributedString? {
|
||||
return _cachedFormattedText["\(isDark)"]
|
||||
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||
}
|
||||
|
||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) {
|
||||
_cachedFormattedText["\(isDark)"] = text
|
||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||
}
|
||||
|
||||
// Codable implementation
|
||||
|
||||
@@ -4,6 +4,11 @@ import Foundation
|
||||
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||
enum Geohash {
|
||||
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.
|
||||
/// - Parameters:
|
||||
@@ -56,5 +61,54 @@ enum 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)
|
||||
}
|
||||
|
||||
/// Decodes a geohash into its latitude and longitude bounds.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: 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()
|
||||
}
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,70 @@ import Foundation
|
||||
|
||||
/// Levels of location channels mapped to geohash precisions.
|
||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case street
|
||||
case block
|
||||
case neighborhood
|
||||
case city
|
||||
case region
|
||||
case country
|
||||
case province // previously .region
|
||||
case region // previously .country
|
||||
|
||||
/// Geohash length used for this level.
|
||||
var precision: Int {
|
||||
switch self {
|
||||
case .street: return 8
|
||||
case .block: return 7
|
||||
case .neighborhood: return 6
|
||||
case .city: return 5
|
||||
case .region: return 4
|
||||
case .country: return 2
|
||||
}
|
||||
case .province: return 4
|
||||
case .region: return 2
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .street: return "Street"
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "Neighborhood"
|
||||
case .city: return "City"
|
||||
case .province: return "Province"
|
||||
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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class AutocompleteService {
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
private let commands = [
|
||||
"/msg", "/who", "/clear", "/help",
|
||||
"/msg", "/who", "/clear",
|
||||
"/hug", "/slap", "/fav", "/unfav",
|
||||
"/block", "/unblock"
|
||||
]
|
||||
@@ -95,7 +95,7 @@ class AutocompleteService {
|
||||
|
||||
private func needsArgument(command: String) -> Bool {
|
||||
switch command {
|
||||
case "/who", "/clear", "/help":
|
||||
case "/who", "/clear":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
|
||||
+733
-196
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,15 @@ class CommandProcessor {
|
||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
let inGeoPublic: Bool = {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .mesh: return false
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
return handleMessage(args)
|
||||
@@ -49,11 +58,14 @@ class CommandProcessor {
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
//
|
||||
case "/help", "/h":
|
||||
return handleHelp()
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
}
|
||||
@@ -85,19 +97,34 @@ class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
let names = people.map { $0.displayName }
|
||||
if names.isEmpty { return .success(message: "no one else is online right now") }
|
||||
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
||||
case .mesh:
|
||||
// Mesh context: show connected peer nicknames
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
}
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
}
|
||||
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.messages.removeAll()
|
||||
chatViewModel?.clearCurrentPublicTimeline()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
@@ -136,8 +163,8 @@ class CommandProcessor {
|
||||
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
}
|
||||
} else {
|
||||
// In public chat: send to mesh and also add a local system echo so sender sees it immediately
|
||||
meshService?.sendMessage(emoteContent, mentions: [])
|
||||
// In public chat: send to active public channel (mesh or geohash)
|
||||
chatViewModel?.sendPublicRaw(emoteContent)
|
||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||
}
|
||||
@@ -149,58 +176,75 @@ class CommandProcessor {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users
|
||||
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else {
|
||||
return .success(message: "no blocked peers")
|
||||
}
|
||||
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID),
|
||||
blockedUsers.contains(fingerprint) {
|
||||
meshBlocked.contains(fingerprint) {
|
||||
blockedNicknames.append(nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)"
|
||||
: blockedNicknames.sorted().joined(separator: ", ")
|
||||
return .success(message: "blocked peers: \(list)")
|
||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||
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
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
// Block the user (mesh/noise identity)
|
||||
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||
identity.isBlocked = true
|
||||
identity.isFavorite = false
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||
} else {
|
||||
let blockedIdentity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: nickname,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: true,
|
||||
notes: nil
|
||||
)
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
|
||||
// Block the user
|
||||
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||
identity.isBlocked = true
|
||||
identity.isFavorite = false
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||
} else {
|
||||
let blockedIdentity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: nickname,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: true,
|
||||
notes: nil
|
||||
)
|
||||
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 .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
@@ -211,19 +255,23 @@ class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) else {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
|
||||
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
// 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")
|
||||
}
|
||||
|
||||
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
||||
// The SecureIdentityStateManager handles the unblocking state
|
||||
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
@@ -17,38 +17,63 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private let userDefaultsKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// Published state for UI bindings
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@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() {
|
||||
super.init()
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = 1000 // meters; we're not tracking continuously
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||
// Load selection
|
||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
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)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, *) {
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, *) {
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
@@ -60,7 +85,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
@@ -74,18 +99,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||
// Prefer continuous updates with a significant distance filter rather than polling
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = 21 // meters; update on small moves
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop periodic refreshes when selector UI is dismissed.
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
@@ -94,6 +131,38 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
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):
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +179,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+
|
||||
@available(iOS 14.0, *)
|
||||
// iOS 14+ / macOS 11+
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
@@ -123,6 +192,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeIfNeeded(location: loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
@@ -138,7 +208,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
@@ -151,8 +221,78 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in self.availableChannels = result }
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -37,11 +37,11 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
let hasMesh = mesh.isPeerConnected(peerID)
|
||||
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
if hasMesh && hasEstablished {
|
||||
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
@@ -57,9 +57,9 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
@@ -70,7 +70,9 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
@@ -101,7 +103,7 @@ final class MessageRouter {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
|
||||
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
private var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
@@ -31,6 +31,7 @@ final class NostrTransport: Transport {
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
|
||||
@@ -48,16 +49,7 @@ final class NostrTransport: Transport {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
// Resolve favorite by full noise key or by short peerID fallback
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
@@ -105,15 +97,7 @@ final class NostrTransport: Transport {
|
||||
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||
let item = readQueue.removeFirst()
|
||||
Task { @MainActor in
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: item.peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, item.peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
@@ -149,15 +133,7 @@ final class NostrTransport: Transport {
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
||||
@@ -183,18 +159,26 @@ final class NostrTransport: Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@MainActor
|
||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if peerID.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
@@ -90,6 +90,15 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
||||
let deeplink = "bitchat://geohash/\(geohash)"
|
||||
let userInfo: [String: Any] = ["deeplink": deeplink]
|
||||
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
|
||||
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = 1337
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: String) {
|
||||
|
||||
@@ -12,14 +12,27 @@ struct RelayController {
|
||||
static func decide(ttl: UInt8,
|
||||
senderIsSelf: Bool,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
// Suppress obvious non-relays
|
||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs
|
||||
// For session-critical or directed traffic, be deterministic and reliable
|
||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||
// Always relay with no TTL cap for these types
|
||||
let newTTL = (ttl &- 1)
|
||||
// Slight jitter to desynchronize without adding too much latency
|
||||
// Tighter for faster multi-hop handshakes and directed DMs
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||
let delayMs = Int.random(in: delayRange)
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
|
||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
||||
let baseProb: Double
|
||||
switch degree {
|
||||
case 0...2: baseProb = 1.0
|
||||
@@ -28,20 +41,29 @@ struct RelayController {
|
||||
case 7...9: baseProb = 0.55
|
||||
default: baseProb = 0.45
|
||||
}
|
||||
var prob = baseProb
|
||||
if isHandshake { prob = max(0.3, baseProb - 0.2) }
|
||||
|
||||
// Sample a forwarding decision
|
||||
let prob = baseProb
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping in dense graphs
|
||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep very low to avoid floods
|
||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
||||
// - Announces in sparse graphs get a bit more headroom
|
||||
let ttlCap: UInt8 = {
|
||||
if degree >= highDegreeThreshold { return 3 }
|
||||
return isAnnounce ? 7 : 6
|
||||
}()
|
||||
let clamped = max(1, min(ttl, ttlCap))
|
||||
let newTTL = clamped &- 1
|
||||
|
||||
// Short jitter to desynchronize rebroadcasts
|
||||
let delayMs = Int.random(in: 20...80)
|
||||
// Wider jitter window to allow duplicate suppression to win more often
|
||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||
let delayMs: Int
|
||||
switch degree {
|
||||
case 0...2: delayMs = Int.random(in: 10...40)
|
||||
case 3...5: delayMs = Int.random(in: 60...150)
|
||||
case 6...9: delayMs = Int.random(in: 80...180)
|
||||
default: delayMs = Int.random(in: 100...220)
|
||||
}
|
||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import Combine
|
||||
|
||||
/// Abstract transport interface used by ChatViewModel and services.
|
||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||
struct TransportPeerSnapshot {
|
||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let id: String
|
||||
let nickname: String
|
||||
let isConnected: Bool
|
||||
@@ -29,6 +29,7 @@ protocol Transport: AnyObject {
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func isPeerReachable(_ peerID: String) -> Bool
|
||||
func peerNickname(peerID: String) -> String?
|
||||
func getPeerNicknames() -> [String: String]
|
||||
|
||||
@@ -46,11 +47,20 @@ protocol Transport: AnyObject {
|
||||
func sendBroadcastAnnounce()
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Peer snapshots (for non-UI services)
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||
}
|
||||
|
||||
protocol TransportPeerEventsDelegate: AnyObject {
|
||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
/// Centralized knobs for transport- and UI-related limits.
|
||||
/// Keep values aligned with existing behavior when replacing magic numbers.
|
||||
enum TransportConfig {
|
||||
// BLE / Protocol
|
||||
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
|
||||
// UI / Storage Caps
|
||||
static let privateChatCap: Int = 1337
|
||||
static let meshTimelineCap: Int = 1337
|
||||
static let geoTimelineCap: Int = 1337
|
||||
static let contentLRUCap: Int = 2000
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||
|
||||
// BLE duty/announce/connect
|
||||
static let bleConnectRateLimitInterval: TimeInterval = 0.5
|
||||
static let bleMaxCentralLinks: Int = 6
|
||||
static let bleDutyOnDuration: TimeInterval = 5.0
|
||||
static let bleDutyOffDuration: TimeInterval = 10.0
|
||||
static let bleAnnounceMinInterval: TimeInterval = 1.0
|
||||
|
||||
// BLE discovery/quality thresholds
|
||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||
static let bleConnectionCandidatesMax: Int = 100
|
||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||
static let blePendingNotificationsCapCount: Int = 20
|
||||
|
||||
// Nostr
|
||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
|
||||
// UI thresholds
|
||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
||||
static let uiProcessedNostrEventsCap: Int = 2000
|
||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||
|
||||
// UI rate limiters (token buckets)
|
||||
static let uiSenderRateBucketCapacity: Double = 5
|
||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||
static let uiContentRateBucketCapacity: Double = 3
|
||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||
|
||||
// UI sleeps/delays
|
||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||
static let uiStartupShortSleepNs: UInt64 = 200_000_000
|
||||
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
||||
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
|
||||
static let uiAsyncMediumSleepNs: UInt64 = 500_000_000
|
||||
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
||||
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
||||
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
||||
static let uiScrollThrottleSeconds: TimeInterval = 0.5
|
||||
static let uiAnimationShortSeconds: TimeInterval = 0.15
|
||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||
|
||||
// BLE maintenance & thresholds
|
||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||
static let bleMaintenanceLeewaySeconds: Int = 1
|
||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
||||
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
||||
static let bleRecentTimeoutCountThreshold: Int = 3
|
||||
static let bleRSSIIsolatedBase: Int = -90
|
||||
static let bleRSSIIsolatedRelaxed: Int = -92
|
||||
static let bleRSSIConnectedThreshold: Int = -85
|
||||
static let bleRSSIHighTimeoutThreshold: Int = -80
|
||||
// How long without seeing traffic before we sanity-check the direct link
|
||||
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
||||
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
||||
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
||||
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
||||
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
|
||||
static let bleRecentPacketWindowMaxCount: Int = 100
|
||||
// Keep scanning fully ON when we saw traffic very recently
|
||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||
static let bleExpectedWriteMaxMs: Int = 2000
|
||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||
static let bleFragmentSpacingMs: Int = 5
|
||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
|
||||
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
|
||||
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
|
||||
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
|
||||
|
||||
// Location
|
||||
static let locationDistanceFilterMeters: Double = 1000
|
||||
// Live (channel sheet open) distance threshold for meaningful updates
|
||||
static let locationDistanceFilterLiveMeters: Double = 21.0
|
||||
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||
|
||||
// Notifications (geohash)
|
||||
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
|
||||
static let uiGeoNotifySnippetMaxLen: Int = 80
|
||||
|
||||
// Nostr geohash
|
||||
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
|
||||
static let nostrGeohashInitialLimit: Int = 200
|
||||
static let nostrGeoRelayCount: Int = 5
|
||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||
static let nostrGeohashSampleLimit: Int = 100
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||
|
||||
// Nostr helpers
|
||||
static let nostrShortKeyDisplayLength: Int = 8
|
||||
static let nostrConvKeyPrefixLength: Int = 16
|
||||
|
||||
// Compression
|
||||
static let compressionThresholdBytes: Int = 100
|
||||
|
||||
// Message deduplication
|
||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||
static let messageDedupMaxCount: Int = 1000
|
||||
|
||||
// Verification QR
|
||||
static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60
|
||||
|
||||
// Nostr relay backoff
|
||||
static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0
|
||||
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
|
||||
static let nostrRelayBackoffMultiplier: Double = 2.0
|
||||
static let nostrRelayMaxReconnectAttempts: Int = 10
|
||||
static let nostrRelayDefaultFetchLimit: Int = 100
|
||||
|
||||
// Geo relay directory
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
|
||||
// BLE operational delays
|
||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
|
||||
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
|
||||
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
|
||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||
|
||||
// Store-and-forward for directed packets at relays
|
||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||
|
||||
// Log/UI debounce windows
|
||||
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
||||
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
||||
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
||||
|
||||
// Weak-link cooldown after connection timeouts
|
||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||
static let bleWeakLinkRSSICutoff: Int = -90
|
||||
|
||||
// Content hashing / formatting
|
||||
static let contentKeyPrefixLength: Int = 256
|
||||
static let uiLongMessageLengthThreshold: Int = 2000
|
||||
static let uiVeryLongTokenThreshold: Int = 512
|
||||
static let uiLongMessageLineLimit: Int = 30
|
||||
static let uiFingerprintSampleCount: Int = 3
|
||||
|
||||
// UI swipe/gesture thresholds
|
||||
static let uiBackSwipeTranslationLarge: CGFloat = 50
|
||||
static let uiBackSwipeTranslationSmall: CGFloat = 30
|
||||
static let uiBackSwipeVelocityThreshold: CGFloat = 300
|
||||
|
||||
// UI color tuning
|
||||
static let uiColorHueAvoidanceDelta: Double = 0.05
|
||||
static let uiColorHueOffset: Double = 0.12
|
||||
// Peer list palette
|
||||
static let uiPeerPaletteSlots: Int = 36
|
||||
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
|
||||
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
|
||||
|
||||
// UI windowing (infinite scroll)
|
||||
static let uiWindowInitialCountPublic: Int = 300
|
||||
static let uiWindowInitialCountPrivate: Int = 300
|
||||
static let uiWindowStepCount: Int = 200
|
||||
|
||||
// Share extension
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
}
|
||||
@@ -68,24 +68,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func updatePeers() {
|
||||
let meshPeers = meshService.currentPeerSnapshots()
|
||||
// If we have no direct links at all, peers should not be marked reachable
|
||||
// "Reachable" means mesh-attached via at least one live link.
|
||||
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Phase 1: Add all connected mesh peers
|
||||
for peerInfo in meshPeers where peerInfo.isConnected {
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.id
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
connected.insert(peerID)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
// Update fingerprint cache
|
||||
@@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Phase 3: Sort peers
|
||||
enrichedPeers.sort { lhs, rhs in
|
||||
// Connected first
|
||||
if lhs.isConnected != rhs.isConnected {
|
||||
return lhs.isConnected
|
||||
}
|
||||
// Then favorites
|
||||
if lhs.isFavorite != rhs.isFavorite {
|
||||
return lhs.isFavorite
|
||||
}
|
||||
// Connectivity rank: connected > reachable > others
|
||||
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
||||
let lr = rank(lhs), rr = rank(rhs)
|
||||
if lr != rr { return lr > rr }
|
||||
// Then favorites inside same rank
|
||||
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
||||
// Finally alphabetical
|
||||
return lhs.displayName < rhs.displayName
|
||||
}
|
||||
@@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Update published properties
|
||||
self.peers = enrichedPeers
|
||||
// Phase 5: Filter out offline non-mutual peers and update published properties
|
||||
let filtered = enrichedPeers.filter { p in
|
||||
p.isConnected || p.isReachable || p.isMutualFavorite
|
||||
}
|
||||
self.peers = filtered
|
||||
self.connectedPeerIDs = connected
|
||||
self.favorites = favoritesList
|
||||
self.mutualFavorites = mutualsList
|
||||
@@ -162,14 +167,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func buildPeerFromMesh(
|
||||
peerInfo: TransportPeerSnapshot,
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||
meshAttached: Bool
|
||||
) -> BitchatPeer {
|
||||
// Determine reachability based on lastSeen and identity trust
|
||||
let now = Date()
|
||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
||||
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
||||
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerInfo.id,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
isConnected: true
|
||||
isConnected: peerInfo.isConnected,
|
||||
isReachable: isReachable
|
||||
)
|
||||
|
||||
// Check for favorite status
|
||||
@@ -216,7 +233,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
isConnected: false
|
||||
isConnected: false,
|
||||
isReachable: false
|
||||
)
|
||||
|
||||
peer.favoriteStatus = favorite
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||
final class VerificationService {
|
||||
static let shared = VerificationService()
|
||||
|
||||
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||
private var noise: NoiseEncryptionService?
|
||||
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||
|
||||
/// Encapsulates the data encoded into a verification QR
|
||||
struct VerificationQR: Codable {
|
||||
let v: Int
|
||||
let noiseKeyHex: String
|
||||
let signKeyHex: String
|
||||
let npub: String?
|
||||
let nickname: String
|
||||
let ts: Int64
|
||||
let nonceB64: String
|
||||
var sigHex: String
|
||||
|
||||
static let context = "bitchat-verify-v1"
|
||||
|
||||
/// Canonical bytes used for signature (deterministic ordering)
|
||||
func canonicalBytes() -> Data {
|
||||
var out = Data()
|
||||
func appendField(_ s: String) {
|
||||
let d = s.data(using: .utf8) ?? Data()
|
||||
out.append(UInt8(min(d.count, 255)))
|
||||
out.append(d.prefix(255))
|
||||
}
|
||||
appendField(Self.context)
|
||||
appendField(String(v))
|
||||
appendField(noiseKeyHex.lowercased())
|
||||
appendField(signKeyHex.lowercased())
|
||||
appendField(npub ?? "")
|
||||
appendField(nickname)
|
||||
appendField(String(ts))
|
||||
appendField(nonceB64)
|
||||
return out
|
||||
}
|
||||
|
||||
func toURLString() -> String {
|
||||
var comps = URLComponents()
|
||||
comps.scheme = "bitchat"
|
||||
comps.host = "verify"
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "v", value: String(v)),
|
||||
URLQueryItem(name: "noise", value: noiseKeyHex),
|
||||
URLQueryItem(name: "sign", value: signKeyHex),
|
||||
URLQueryItem(name: "nick", value: nickname),
|
||||
URLQueryItem(name: "ts", value: String(ts)),
|
||||
URLQueryItem(name: "nonce", value: nonceB64),
|
||||
URLQueryItem(name: "sig", value: sigHex)
|
||||
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
|
||||
return comps.string ?? ""
|
||||
}
|
||||
|
||||
static func fromURL(_ url: URL) -> VerificationQR? {
|
||||
guard url.scheme == "bitchat", url.host == "verify",
|
||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
|
||||
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
|
||||
guard let vStr = val("v"), let v = Int(vStr),
|
||||
let noise = val("noise"), let sign = val("sign"),
|
||||
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
|
||||
let nonce = val("nonce"), let sig = val("sig") else { return nil }
|
||||
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Build a signed QR string for the current identity
|
||||
func buildMyQRString(nickname: String, npub: String?) -> String? {
|
||||
// Simple short-lived cache to speed up sheet opening
|
||||
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
|
||||
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||
return c.value
|
||||
}
|
||||
guard let noise = noise else { return nil }
|
||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
guard let sig = noise.signData(msg) else { return nil }
|
||||
let signed = VerificationQR(v: payload.v,
|
||||
noiseKeyHex: payload.noiseKeyHex,
|
||||
signKeyHex: payload.signKeyHex,
|
||||
npub: payload.npub,
|
||||
nickname: payload.nickname,
|
||||
ts: payload.ts,
|
||||
nonceB64: payload.nonceB64,
|
||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||
let out = signed.toURLString()
|
||||
Cache.last = (nickname, npub, Date(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
||||
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||
// Freshness
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let noise = noise else { return nil }
|
||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
return ok ? qr : nil
|
||||
}
|
||||
|
||||
// MARK: - Noise payloads (scaffold only)
|
||||
|
||||
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
|
||||
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
|
||||
var tlv = Data()
|
||||
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
|
||||
tlv.append(contentsOf: n0)
|
||||
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
|
||||
tlv.append(0x02)
|
||||
tlv.append(UInt8(min(nonceA.count, 255)))
|
||||
tlv.append(nonceA.prefix(255))
|
||||
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
|
||||
}
|
||||
|
||||
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
|
||||
// Sign context: verify-response | noiseKeyHex | nonceA
|
||||
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||
var tlv = Data()
|
||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
|
||||
return NoisePayload(type: .verifyResponse, data: tlv).encode()
|
||||
}
|
||||
|
||||
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
|
||||
var idx = 0
|
||||
func take(_ n: Int) -> Data? {
|
||||
guard idx + n <= data.count else { return nil }
|
||||
let d = data[idx..<(idx+n)]
|
||||
idx += n
|
||||
return Data(d)
|
||||
}
|
||||
// Expect type already stripped; we receive only TLV here
|
||||
// TLV 0x01 noiseKeyHex
|
||||
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||
// TLV 0x02 nonceA
|
||||
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||
return (noiseStr, nA)
|
||||
}
|
||||
|
||||
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
|
||||
var idx = 0
|
||||
func take(_ n: Int) -> Data? {
|
||||
guard idx + n <= data.count else { return nil }
|
||||
let d = data[idx..<(idx+n)]
|
||||
idx += n
|
||||
return Data(d)
|
||||
}
|
||||
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
|
||||
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
|
||||
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
|
||||
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
|
||||
return (noiseStr, nA, sig)
|
||||
}
|
||||
|
||||
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
|
||||
var msg = Data("bitchat-verify-resp-v1".utf8)
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import Compression
|
||||
|
||||
struct CompressionUtil {
|
||||
// Compression threshold - don't compress if data is smaller than this
|
||||
static let compressionThreshold = 100 // bytes
|
||||
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
||||
|
||||
// Compress data using zlib algorithm (most compatible)
|
||||
static func compress(_ data: Data) -> Data? {
|
||||
|
||||
@@ -18,14 +18,18 @@ struct InputValidator {
|
||||
|
||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
// Accept short routing IDs (16-hex)
|
||||
// Accept short routing IDs (exact 16-hex)
|
||||
if PeerIDResolver.isShortID(peerID) { return true }
|
||||
// Accept full Noise key hex (64-hex)
|
||||
// If length equals short-hex length but isn't valid hex, reject
|
||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
||||
// Accept full Noise key hex (exact 64-hex)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
||||
// Internal format: alphanumeric + dash/underscore up to 64
|
||||
// If length equals full key length but isn't valid hex, reject
|
||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||
return !peerID.isEmpty &&
|
||||
peerID.count <= Limits.maxPeerIDLength &&
|
||||
peerID.count < Limits.maxPeerIDLength &&
|
||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ final class MessageDeduplicator {
|
||||
private var entries: [Entry] = []
|
||||
private var lookup = Set<String>()
|
||||
private let lock = NSLock()
|
||||
private let maxAge: TimeInterval = 300 // 5 minutes
|
||||
private let maxCount = 1000
|
||||
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
||||
private let maxCount = TransportConfig.messageDedupMaxCount
|
||||
|
||||
/// Check if message is duplicate and add if not
|
||||
func isDuplicate(_ messageID: String) -> Bool {
|
||||
@@ -84,4 +84,3 @@ final class MessageDeduplicator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
|
||||
struct PeerDisplayNameResolver {
|
||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||
/// - Parameters:
|
||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||
/// - Returns: Map of peerID -> displayName.
|
||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
||||
// Count collisions among connected peers and include our own nickname
|
||||
var counts: [String: Int] = [:]
|
||||
for p in peers where p.isConnected {
|
||||
counts[p.nickname, default: 0] += 1
|
||||
}
|
||||
counts[selfNickname, default: 0] += 1
|
||||
|
||||
var result: [String: String] = [:]
|
||||
for p in peers {
|
||||
var name = p.nickname
|
||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||
name += "#" + String(p.id.prefix(4))
|
||||
}
|
||||
result[p.id] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
+1576
-366
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@ struct AppInfoView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("DONE") {
|
||||
Button("close") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
|
||||
+542
-238
File diff suppressed because it is too large
Load Diff
@@ -32,17 +32,36 @@ struct FingerprintView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("DONE") {
|
||||
dismiss()
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Peer info
|
||||
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
let statusPeerID: String = {
|
||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||
return peerID
|
||||
}()
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
|
||||
HStack {
|
||||
if let icon = encryptionStatus.icon {
|
||||
@@ -73,7 +92,7 @@ struct FingerprintView: View {
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||
Text(formatFingerprint(fingerprint))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
@@ -165,6 +184,20 @@ struct FingerprintView: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Button(action: {
|
||||
viewModel.unverifyFingerprint(for: peerID)
|
||||
dismiss()
|
||||
}) {
|
||||
Text("REMOVE VERIFICATION")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.red)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
@@ -1,51 +1,89 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct GeohashPeopleList: View {
|
||||
@ObservedObject var viewModel: ChatViewModel
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
let onTapPerson: () -> Void
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.geohashPeople.isEmpty {
|
||||
if viewModel.visibleGeohashPeople().isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let ordered = viewModel.geohashPeople.sorted { a, b in
|
||||
if let me = myHex {
|
||||
if a.id == me && b.id != me { return true }
|
||||
if b.id == me && a.id != me { return false }
|
||||
}
|
||||
return a.lastSeen > b.lastSeen
|
||||
}
|
||||
} else {
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
ForEach(ordered) { person in
|
||||
return nil
|
||||
}()
|
||||
let people = viewModel.visibleGeohashPeople()
|
||||
let currentIDs = people.map { $0.id }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
let convKey = "nostr_" + String(person.id.prefix(16))
|
||||
if viewModel.unreadPrivateMessages.contains(convKey) {
|
||||
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
|
||||
} else {
|
||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
|
||||
let isMe = (person.id == myHex)
|
||||
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
|
||||
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))
|
||||
.fontWeight(isMe ? .bold : .regular)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(person.id == myHex ? .bold : .regular)
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, person.id == firstID ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if person.id != myHex {
|
||||
@@ -53,10 +91,43 @@ struct GeohashPeopleList: View {
|
||||
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
|
||||
|
||||
// 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, "")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
import CoreLocation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -37,11 +41,7 @@ struct LocationChannelsSheet: View {
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("open settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("open settings") { openSystemLocationSettings() }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -54,6 +54,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
@@ -61,8 +62,21 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button("close") { isPresented = false }
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Refresh channels when opening
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
@@ -70,30 +84,23 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
manager.beginLiveRefresh()
|
||||
// Begin multi-channel sampling for counts
|
||||
let ghs = manager.availableChannels.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
// Geohash sampling is now managed by ChatViewModel globally
|
||||
}
|
||||
.onDisappear {
|
||||
manager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
.onChange(of: manager.permissionState) { newValue in
|
||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { newValue in
|
||||
// Keep sampling list in sync with available channels as they refresh live
|
||||
let ghs = newValue.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { _ in }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
@@ -101,7 +108,28 @@ struct LocationChannelsSheet: View {
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
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,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
@@ -121,10 +149,12 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.keyboardType(.asciiCapable)
|
||||
#endif
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
@@ -140,13 +170,22 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button("teleport") {
|
||||
Button(action: {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = "invalid geohash"; return }
|
||||
let level = levelForLength(gh.count)
|
||||
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))
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("teleport")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
Image(systemName: "face.dashed")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -164,12 +203,52 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("bookmarked")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Text("remove location access")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
@@ -198,24 +277,49 @@ struct LocationChannelsSheet: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
@ViewBuilder
|
||||
private func channelRow(
|
||||
title: String,
|
||||
subtitlePrefix: String,
|
||||
subtitleName: String? = nil,
|
||||
subtitleNameBold: Bool = false,
|
||||
isSelected: Bool,
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 0) {
|
||||
Text(subtitlePrefix)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.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)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
@@ -223,11 +327,11 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -241,22 +345,34 @@ struct LocationChannelsSheet: View {
|
||||
// MARK: - Helpers for counts
|
||||
private func meshTitleWithCount() -> String {
|
||||
// Count currently connected mesh peers (excluding self)
|
||||
let meshCount = meshCount()
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
let meshCount = viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "#mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
@@ -265,12 +381,12 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .country
|
||||
case 3...4: return .region
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .street
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,6 +396,94 @@ extension LocationChannelsSheet {
|
||||
private var standardGreen: Color {
|
||||
(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)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
// 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, macOS 13.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, macOS 13.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 "~"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Settings helper
|
||||
private func openSystemLocationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -7,71 +7,122 @@ struct MeshPeerList: View {
|
||||
let onTapPeer: (String) -> Void
|
||||
let onToggleFavorite: (String) -> Void
|
||||
let onShowFingerprint: (String) -> Void
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.allPeers.isEmpty {
|
||||
if viewModel.allPeers.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.id == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
let peers = mapped.sorted { lhs, rhs in
|
||||
let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false
|
||||
let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false
|
||||
if lFav != rFav { return lFav }
|
||||
let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname
|
||||
let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname
|
||||
return lhsName < rhsName
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||
let isMe = peer.id == myPeerID
|
||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
||||
return (peer, isMe, hasUnread, enc)
|
||||
}
|
||||
// Stable visual order without mutating state here
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||
mapped.first(where: { $0.peer.id == id })
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<peers.count, id: \.self) { idx in
|
||||
let item = peers[idx]
|
||||
let peer = item.peer
|
||||
let isMe = item.isMe
|
||||
let hasUnread = item.hasUnread
|
||||
HStack(spacing: 4) {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
|
||||
} else if hasUnread {
|
||||
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isConnected {
|
||||
// Mesh-connected peer: radio icon
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
} else {
|
||||
switch peer.connectionState {
|
||||
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)
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.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 !isMe {
|
||||
if peer.isConnected {
|
||||
if let icon = item.enc.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
// Offline: prefer showing verified badge from persisted fingerprints
|
||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if let icon = item.enc.icon {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
Text(displayName)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
|
||||
|
||||
if let icon = item.enc.icon, !isMe {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Unread message indicator for this peer
|
||||
if !isMe, item.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help("New messages")
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
@@ -83,12 +134,36 @@ struct MeshPeerList: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 6 : 0)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Placeholder view to display the user's verification QR payload as text.
|
||||
struct MyQRView: View {
|
||||
let qrString: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("scan to verify me")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
QRCodeImage(data: qrString, size: 240)
|
||||
.accessibilityLabel("verification qr code")
|
||||
|
||||
// Non-scrolling, fully visible URL (wraps across lines)
|
||||
Text(qrString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(8)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// Render a QR code image for a given string using CoreImage
|
||||
struct QRCodeImage: View {
|
||||
let data: String
|
||||
let size: CGFloat
|
||||
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = generateImage() {
|
||||
ImageWrapper(image: image)
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text("qr unavailable")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateImage() -> CGImage? {
|
||||
let inputData = Data(data.utf8)
|
||||
filter.message = inputData
|
||||
filter.correctionLevel = "M"
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let scale = max(1, Int(size / 32))
|
||||
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
||||
return context.createCGImage(transformed, from: transformed.extent)
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific wrapper to display CGImage in SwiftUI
|
||||
struct ImageWrapper: View {
|
||||
let image: CGImage
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
let ui = UIImage(cgImage: image)
|
||||
return Image(uiImage: ui)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
#else
|
||||
let ns = NSImage(cgImage: image, size: .zero)
|
||||
return Image(nsImage: ns)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
var isActive: Bool = true
|
||||
@State private var input = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS)
|
||||
CameraScannerView(isActive: isActive) { code in
|
||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
if !ok { /* already pending; continue scanning */ }
|
||||
lastValid = code
|
||||
} else {
|
||||
// ignore invalid reads; continue scanning
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#else
|
||||
Text("paste qr content to validate:")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
Button("validate") {
|
||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||
let ok = viewModel.beginQRVerification(with: qr)
|
||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
||||
} else {
|
||||
result = "invalid or expired qr payload"
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
#endif
|
||||
// No status text under camera per design
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import AVFoundation
|
||||
|
||||
struct CameraScannerView: UIViewRepresentable {
|
||||
typealias UIViewType = PreviewView
|
||||
var isActive: Bool
|
||||
var onCode: (String) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||
context.coordinator.setActive(isActive)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||
context.coordinator.setActive(isActive)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private var onCode: ((String) -> Void)?
|
||||
private weak var owner: PreviewView?
|
||||
private let session = AVCaptureSession()
|
||||
private var isRunning = false
|
||||
private var permissionGranted = false
|
||||
private var desiredActive = false
|
||||
|
||||
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||
self.owner = sessionOwner
|
||||
self.onCode = onCode
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||
output.metadataObjectTypes = [.qr]
|
||||
}
|
||||
session.commitConfiguration()
|
||||
sessionOwner.videoPreviewLayer.session = session
|
||||
// Request permission and start
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
self.permissionGranted = granted
|
||||
if granted && self.desiredActive && !self.isRunning {
|
||||
self.setActive(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
desiredActive = active
|
||||
guard permissionGranted else { return }
|
||||
if active && !isRunning {
|
||||
isRunning = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !self.session.isRunning { self.session.startRunning() }
|
||||
}
|
||||
} else if !active && isRunning {
|
||||
isRunning = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
for obj in metadataObjects {
|
||||
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||
m.type == .qr,
|
||||
let str = m.stringValue else { continue }
|
||||
onCode?(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
videoPreviewLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Combined sheet: shows my QR by default with a button to scan instead
|
||||
struct VerificationSheetView: View {
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@State private var showingScanner = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
|
||||
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
|
||||
private func myQRString() -> String {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Top header (always at top)
|
||||
HStack {
|
||||
Text("VERIFY")
|
||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showingScanner = false
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content area
|
||||
Group {
|
||||
if showingScanner {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("scan a friend's qr")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
#if os(iOS)
|
||||
QRScanView(isActive: showingScanner)
|
||||
.environmentObject(viewModel)
|
||||
.frame(height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
QRScanView()
|
||||
.environmentObject(viewModel)
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(boxColor)
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
let qr = myQRString()
|
||||
MyQRView(qrString: qr)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
// Centered controls moved up
|
||||
VStack(spacing: 10) {
|
||||
if showingScanner {
|
||||
Button(action: { showingScanner = false }) {
|
||||
Label("show my qr", systemImage: "qrcode")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(action: { showingScanner = true }) {
|
||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
}
|
||||
|
||||
// Optional: Remove verification for selected peer (if verified)
|
||||
if let pid = viewModel.selectedPrivateChatPeer,
|
||||
let fp = viewModel.getFingerprint(for: pid),
|
||||
viewModel.verifiedFingerprints.contains(fp) {
|
||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||
Label("remove verification", systemImage: "minus.circle")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
.onDisappear { showingScanner = false }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -7,160 +7,160 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
/// Modern share extension using UIKit + UTTypes.
|
||||
/// Avoids deprecated Social framework and SLComposeServiceViewController.
|
||||
final class ShareViewController: UIViewController {
|
||||
private let statusLabel: UILabel = {
|
||||
let l = UILabel()
|
||||
l.translatesAutoresizingMaskIntoConstraints = false
|
||||
l.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
l.textAlignment = .center
|
||||
l.numberOfLines = 0
|
||||
l.textColor = .label
|
||||
return l
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Set placeholder text
|
||||
placeholder = "Share to bitchat..."
|
||||
// Set character limit (optional)
|
||||
charactersRemaining = 500
|
||||
view.backgroundColor = .systemBackground
|
||||
view.addSubview(statusLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
|
||||
processShare()
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
// Validate that we have text content or attachments
|
||||
if let text = contentText, !text.isEmpty {
|
||||
return true
|
||||
}
|
||||
// Check if we have attachments
|
||||
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments,
|
||||
!attachments.isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
// MARK: - Processing
|
||||
private func processShare() {
|
||||
guard let ctx = self.extensionContext,
|
||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||
finishWithMessage("Nothing to share")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Get the page title from the compose view or extension item
|
||||
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
|
||||
|
||||
var foundURL: URL? = nil
|
||||
let group = DispatchGroup()
|
||||
|
||||
// IMPORTANT: Check if the NSExtensionItem itself has a URL
|
||||
// Safari often provides the URL as an attributedString with a link
|
||||
if let attributedText = extensionItem.attributedContentText {
|
||||
let text = attributedText.string
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
|
||||
if let firstMatch = matches?.first, let url = firstMatch.url {
|
||||
foundURL = url
|
||||
}
|
||||
// Try content from attributed text first (Safari often passes URL here)
|
||||
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
|
||||
saveAndFinish(url: url, title: item.attributedTitle?.string)
|
||||
return
|
||||
}
|
||||
|
||||
// Only check attachments if we haven't found a URL yet
|
||||
if foundURL == nil {
|
||||
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
|
||||
|
||||
// Try multiple URL type identifiers that Safari might use
|
||||
let urlTypes = [
|
||||
UTType.url.identifier,
|
||||
"public.url",
|
||||
"public.file-url"
|
||||
]
|
||||
|
||||
for urlType in urlTypes {
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
|
||||
group.enter()
|
||||
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
|
||||
defer { group.leave() }
|
||||
|
||||
if let url = item as? URL {
|
||||
foundURL = url
|
||||
} else if let data = item as? Data,
|
||||
let urlString = String(data: data, encoding: .utf8),
|
||||
let url = URL(string: urlString) {
|
||||
foundURL = url
|
||||
} else if let string = item as? String,
|
||||
let url = URL(string: string) {
|
||||
foundURL = url
|
||||
}
|
||||
}
|
||||
break // Found a URL type, no need to check other types
|
||||
}
|
||||
// Scan attachments for URL/text
|
||||
let providers = item.attachments ?? []
|
||||
if providers.isEmpty {
|
||||
// Fallback: use attributed title as plain text
|
||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||
saveAndFinish(text: title)
|
||||
} else {
|
||||
finishWithMessage("No shareable content")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Also check for plain text that might be a URL
|
||||
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
group.enter()
|
||||
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
|
||||
defer { group.leave() }
|
||||
|
||||
if let text = item as? String {
|
||||
// Check if the text is actually a URL
|
||||
if let url = URL(string: text),
|
||||
(url.scheme == "http" || url.scheme == "https") {
|
||||
foundURL = url
|
||||
// Load URL or text asynchronously
|
||||
loadFirstURL(from: providers) { [weak self] url in
|
||||
guard let self = self else { return }
|
||||
if let url = url {
|
||||
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.loadFirstPlainText(from: providers) { text in
|
||||
if let t = text, !t.isEmpty {
|
||||
// Treat as URL if parseable http(s), else plain text
|
||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.saveAndFinish(text: t)
|
||||
}
|
||||
} else {
|
||||
self.finishWithMessage("No shareable content")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // End of if foundURL == nil
|
||||
}
|
||||
|
||||
// Process after all checks complete
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
if let url = foundURL {
|
||||
// We have a URL! Create the JSON data
|
||||
let urlData: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": pageTitle ?? url.host ?? "Shared Link"
|
||||
]
|
||||
private func detectURL(in text: String) -> URL? {
|
||||
guard !text.isEmpty else { return nil }
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(location: 0, length: (text as NSString).length)
|
||||
let match = detector?.matches(in: text, options: [], range: range).first
|
||||
return match?.url
|
||||
}
|
||||
|
||||
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
|
||||
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
|
||||
let grp = DispatchGroup()
|
||||
var found: URL?
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
self?.saveToSharedDefaults(content: jsonString, type: "url")
|
||||
for p in providers where found == nil {
|
||||
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
|
||||
grp.enter()
|
||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
||||
defer { grp.leave() }
|
||||
if let u = item as? URL { found = u; return }
|
||||
if let s = item as? String, let u = URL(string: s) { found = u; return }
|
||||
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
|
||||
}
|
||||
} else if let title = pageTitle, !title.isEmpty {
|
||||
// No URL found, just share the text
|
||||
self?.saveToSharedDefaults(content: title, type: "text")
|
||||
break
|
||||
}
|
||||
}
|
||||
grp.notify(queue: .main) { completion(found) }
|
||||
}
|
||||
|
||||
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
|
||||
let id = UTType.plainText.identifier
|
||||
let grp = DispatchGroup()
|
||||
var text: String?
|
||||
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
|
||||
grp.enter()
|
||||
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
|
||||
defer { grp.leave() }
|
||||
if let s = item as? String { text = s }
|
||||
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
|
||||
}
|
||||
break
|
||||
}
|
||||
grp.notify(queue: .main) { completion(text) }
|
||||
}
|
||||
|
||||
// MARK: - Save + Finish
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? "Shared Link"
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage("✓ Shared link to bitchat")
|
||||
} else {
|
||||
finishWithMessage("Failed to encode link")
|
||||
}
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
// No configuration items needed
|
||||
return []
|
||||
private func saveAndFinish(text: String) {
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage("✓ Shared text to bitchat")
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
// Use app groups to share data between extension and main app
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
|
||||
userDefaults.set(content, forKey: "sharedContent")
|
||||
userDefaults.set(type, forKey: "sharedContentType")
|
||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||
userDefaults.synchronize()
|
||||
|
||||
|
||||
// Force open the main app
|
||||
self.openMainApp()
|
||||
// No need to force synchronize; the system persists changes
|
||||
}
|
||||
|
||||
private func openMainApp() {
|
||||
// Share extensions cannot directly open the containing app
|
||||
// The app will check for shared content when it becomes active
|
||||
// Show success feedback to user
|
||||
DispatchQueue.main.async {
|
||||
self.textView.text = "✓ Shared to bitchat"
|
||||
self.textView.isEditable = false
|
||||
private func finishWithMessage(_ msg: String) {
|
||||
statusLabel.text = msg
|
||||
// Complete shortly after showing status
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear persisted state before each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean after each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
// Start clean
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
// Add with mixed case and hash prefix
|
||||
store.toggle("#U4PRUY")
|
||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||
|
||||
// Toggling again removes
|
||||
store.toggle("u4pruy")
|
||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
}
|
||||
|
||||
func testPersistenceWritten() throws {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||
XCTAssertTrue(arr.contains("ezs42"))
|
||||
XCTAssertTrue(arr.contains("u4pruy"))
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase {
|
||||
// Sanity: known coords (Statue of Liberty approx)
|
||||
let lat = 40.6892
|
||||
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 neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.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 country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
|
||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
||||
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
||||
|
||||
XCTAssertEqual(street.count, 8)
|
||||
XCTAssertEqual(block.count, 7)
|
||||
XCTAssertEqual(neighborhood.count, 6)
|
||||
XCTAssertEqual(city.count, 5)
|
||||
@@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase {
|
||||
XCTAssertEqual(country.count, 2)
|
||||
|
||||
// All prefixes must match progressively
|
||||
XCTAssertTrue(street.hasPrefix(block))
|
||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||
XCTAssertTrue(city.hasPrefix(region))
|
||||
|
||||
@@ -21,8 +21,10 @@ final class NostrProtocolTests: XCTestCase {
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
#if DEBUG
|
||||
print("Sender pubkey: \(sender.publicKeyHex)")
|
||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||
#endif
|
||||
|
||||
// Create a test message
|
||||
let originalContent = "Hello from NIP-17 test!"
|
||||
@@ -34,8 +36,10 @@ final class NostrProtocolTests: XCTestCase {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||
#endif
|
||||
|
||||
// Decrypt the gift wrap
|
||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -52,7 +56,9 @@ final class NostrProtocolTests: XCTestCase {
|
||||
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
||||
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
||||
|
||||
#if DEBUG
|
||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
||||
@@ -75,8 +81,10 @@ final class NostrProtocolTests: XCTestCase {
|
||||
|
||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
||||
#if DEBUG
|
||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||
#endif
|
||||
|
||||
// Both should decrypt successfully
|
||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
@@ -109,7 +117,114 @@ final class NostrProtocolTests: XCTestCase {
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: wrongRecipient
|
||||
)) { error in
|
||||
#if DEBUG
|
||||
print("Expected error when decrypting with wrong key: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_Delivered() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||
let messageID = "TEST-MSG-DELIVERED-1"
|
||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed delivered ack")
|
||||
return
|
||||
}
|
||||
|
||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Ensure v2 format was used for ciphertext
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
// Decrypt as recipient
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
|
||||
// Verify sender is correct
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
// Parse BitChat payload
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .delivered:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
||||
// Identities
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
|
||||
let messageID = "TEST-MSG-READ-1"
|
||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
XCTFail("Failed to embed read ack")
|
||||
return
|
||||
}
|
||||
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
||||
|
||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: recipient
|
||||
)
|
||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
||||
|
||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||
guard let packetData = Self.base64URLDecode(base64url),
|
||||
let packet = BitchatPacket.from(packetData) else {
|
||||
return XCTFail("Failed to decode bitchat packet")
|
||||
}
|
||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
||||
return XCTFail("Failed to decode NoisePayload")
|
||||
}
|
||||
switch payload.type {
|
||||
case .readReceipt:
|
||||
let mid = String(data: payload.data, encoding: .utf8)
|
||||
XCTAssertEqual(mid, messageID)
|
||||
default:
|
||||
XCTFail("Unexpected payload type: \(payload.type)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// LegacyTestProtocolTypes.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Minimal legacy protocol types used only by tests to simulate old flows.
|
||||
// These are not part of production code anymore.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ProtocolNack {
|
||||
let originalPacketID: String
|
||||
let nackID: String
|
||||
let senderID: String
|
||||
let receiverID: String
|
||||
let packetType: UInt8
|
||||
let reason: String
|
||||
let errorCode: UInt8
|
||||
|
||||
enum ErrorCode: UInt8 {
|
||||
case unknown = 0
|
||||
case decryptionFailed = 2
|
||||
}
|
||||
|
||||
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
|
||||
self.originalPacketID = originalPacketID
|
||||
self.nackID = UUID().uuidString
|
||||
self.senderID = senderID
|
||||
self.receiverID = receiverID
|
||||
self.packetType = packetType
|
||||
self.reason = reason
|
||||
self.errorCode = errorCode.rawValue
|
||||
}
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
// Tests don't parse the payload; return a compact encoding for completeness
|
||||
var data = Data()
|
||||
data.appendUUID(originalPacketID)
|
||||
data.appendUUID(nackID)
|
||||
data.append(UInt8(packetType))
|
||||
data.append(UInt8(errorCode))
|
||||
data.appendString(reason)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
Command line invocation:
|
||||
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
|
||||
|
||||
Build settings from command line:
|
||||
CODE_SIGNING_ALLOWED = NO
|
||||
ONLY_ACTIVE_ARCH = YES
|
||||
SDKROOT = iphonesimulator18.5
|
||||
|
||||
Resolve Package Graph
|
||||
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
|
||||
Package: swift-secp256k1
|
||||
|
||||
fatalError
|
||||
@@ -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