mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334c6271b6 | ||
|
|
6f4da62e4e | ||
|
|
d946c01bfc | ||
|
|
ce48720e9a | ||
|
|
e0db8a2bc8 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## bitchat
|
## bitchat
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
[bitchat.free](http://bitchat.free)
|
[bitchat.free](http://bitchat.free)
|
||||||
|
|
||||||
@@ -11,99 +11,50 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
> [!WARNING]
|
> [!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.
|
> 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
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## 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
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||||
|
|
||||||
|
|
||||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||||
|
|
||||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
### 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
|
||||||
|
|
||||||
### Bluetooth Mesh Network (Offline)
|
### Mesh Networking
|
||||||
|
- Each device acts as both client and peripheral
|
||||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
- Automatic peer discovery and connection management
|
||||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
- Adaptive duty cycling for battery optimization
|
||||||
- **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).
|
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||||
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using XcodeGen (Recommended)
|
### Option 1: Using XcodeGen (Recommended)
|
||||||
|
|
||||||
1. Install XcodeGen if you haven't already:
|
1. Install XcodeGen if you haven't already:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install xcodegen
|
brew install xcodegen
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Generate the Xcode project:
|
2. Generate the Xcode project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
### Option 2: Using Swift Package Manager
|
### Option 2: Using Swift Package Manager
|
||||||
|
|
||||||
1. Open the project in Xcode:
|
1. Open the project in Xcode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
open Package.swift
|
open Package.swift
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
|
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
|
||||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||||
|
|
||||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||||
047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; };
|
047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; };
|
||||||
@@ -29,13 +30,6 @@
|
|||||||
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
|
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
|
||||||
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||||
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
|
||||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
|
||||||
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 */; };
|
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||||
@@ -63,8 +57,6 @@
|
|||||||
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
||||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
|
||||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
|
|
||||||
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
|
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
|
||||||
@@ -111,17 +103,11 @@
|
|||||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
|
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
|
||||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||||
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
|
||||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
|
||||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
|
||||||
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
|
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
|
||||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
|
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
|
||||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
|
||||||
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
|
||||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
|
||||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
|
||||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
|
||||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||||
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
|
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
|
||||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
|
||||||
@@ -155,6 +141,12 @@
|
|||||||
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
||||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
|
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
|
||||||
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
|
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
|
||||||
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
|
||||||
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
|
||||||
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -208,9 +200,6 @@
|
|||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
||||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
|
|
||||||
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>"; };
|
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||||
@@ -226,7 +215,6 @@
|
|||||||
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
||||||
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
|
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
|
||||||
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
|
|
||||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
||||||
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
||||||
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
|
||||||
@@ -257,11 +245,9 @@
|
|||||||
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
|
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
|
||||||
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
||||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
|
||||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||||
|
|
||||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
||||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
|
||||||
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
|
||||||
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
@@ -280,6 +266,9 @@
|
|||||||
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
|
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
|
||||||
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
|
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
|
||||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
|
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
|
||||||
|
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -416,7 +405,6 @@
|
|||||||
9A78348821A7D3374607D4E3 /* Utils */ = {
|
9A78348821A7D3374607D4E3 /* Utils */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
|
|
||||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
|
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
|
||||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
|
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
|
||||||
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
|
32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
|
||||||
@@ -455,6 +443,7 @@
|
|||||||
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
|
||||||
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
047502B32E55FED60083520F /* MeshPeerList.swift */,
|
||||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||||
|
|
||||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||||
@@ -489,7 +478,6 @@
|
|||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||||
@@ -526,8 +514,6 @@
|
|||||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
|
||||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
|
||||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
047502B82E560F690083520F /* RelayController.swift */,
|
047502B82E560F690083520F /* RelayController.swift */,
|
||||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||||
@@ -741,7 +727,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
|
|
||||||
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
|
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -750,8 +735,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
|
||||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
|
||||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
@@ -775,11 +758,11 @@
|
|||||||
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
|
||||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
||||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||||
|
|
||||||
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
|
||||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
|
||||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||||
@@ -810,8 +793,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
|
||||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
|
||||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
@@ -835,11 +816,11 @@
|
|||||||
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
|
||||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
||||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
|
||||||
|
|
||||||
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
|
||||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
|
||||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||||
@@ -876,7 +857,6 @@
|
|||||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
|
||||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||||
@@ -899,7 +879,6 @@
|
|||||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
|
||||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||||
@@ -1016,7 +995,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1047,7 +1026,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1102,7 +1081,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1134,7 +1113,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1223,7 +1202,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1316,7 +1295,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -99,15 +99,15 @@ struct BitchatApp: App {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process if shared within configured window
|
// Only process if shared within last 30 seconds
|
||||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
if Date().timeIntervalSince(sharedDate) < 30 {
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||||
|
|
||||||
// Clear the shared content
|
// Clear the shared content
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
// No need to force synchronize here
|
userDefaults.synchronize()
|
||||||
|
|
||||||
// Send the shared content immediately on the main queue
|
// Send the shared content immediately on the main queue
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -173,14 +173,6 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle deeplink (e.g., geohash activity)
|
|
||||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
|
||||||
#if os(iOS)
|
|
||||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
|
||||||
#else
|
|
||||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
@@ -200,15 +192,6 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
|
||||||
let deep = userInfo["deeplink"] as? String,
|
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
// Show notification in all other cases
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
|
|||||||
@@ -106,8 +106,6 @@ enum HandshakeState {
|
|||||||
struct CryptographicIdentity: Codable {
|
struct CryptographicIdentity: Codable {
|
||||||
let fingerprint: String // SHA256 of public key
|
let fingerprint: String // SHA256 of public key
|
||||||
let publicKey: Data // Noise static public key
|
let publicKey: Data // Noise static public key
|
||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
|
||||||
var signingPublicKey: Data? = nil
|
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
let lastHandshake: Date?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,91 +230,6 @@ class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cryptographic Identities
|
|
||||||
|
|
||||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
|
||||||
/// - noisePublicKey: Noise static public key data
|
|
||||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
|
||||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
|
||||||
// Update keys if changed
|
|
||||||
if existing.publicKey != noisePublicKey {
|
|
||||||
existing = CryptographicIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
publicKey: noisePublicKey,
|
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
|
||||||
} else {
|
|
||||||
// Update signing key and lastHandshake
|
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
|
||||||
let updated = CryptographicIdentity(
|
|
||||||
fingerprint: existing.fingerprint,
|
|
||||||
publicKey: existing.publicKey,
|
|
||||||
signingPublicKey: existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
|
||||||
}
|
|
||||||
// Persist updated state (already assigned in branches above)
|
|
||||||
} else {
|
|
||||||
// New entry
|
|
||||||
let entry = CryptographicIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
publicKey: noisePublicKey,
|
|
||||||
signingPublicKey: signingPublicKey,
|
|
||||||
firstSeen: now,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally persist claimed nickname into social identity
|
|
||||||
if let claimed = claimedNickname {
|
|
||||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: claimed,
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
// Update claimed nickname if changed
|
|
||||||
if identity.claimedNickname != claimed {
|
|
||||||
identity.claimedNickname = claimed
|
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
|
||||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.saveIdentityCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve cryptographic identity by fingerprint
|
|
||||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
|
||||||
queue.sync { cryptographicIdentities[fingerprint] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
|
||||||
queue.sync {
|
|
||||||
// Defensive: ensure hex and correct length
|
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
return Array(cache.socialIdentities.values)
|
return Array(cache.socialIdentities.values)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
|
||||||
|
|
||||||
// Favorite-related properties
|
// Favorite-related properties
|
||||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||||
@@ -19,7 +18,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
// Connection state
|
// Connection state
|
||||||
enum ConnectionState {
|
enum ConnectionState {
|
||||||
case bluetoothConnected
|
case bluetoothConnected
|
||||||
case meshReachable // Seen via mesh recently, not directly connected
|
|
||||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||||
case offline // Not connected via any transport
|
case offline // Not connected via any transport
|
||||||
}
|
}
|
||||||
@@ -27,8 +25,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
var connectionState: ConnectionState {
|
var connectionState: ConnectionState {
|
||||||
if isConnected {
|
if isConnected {
|
||||||
return .bluetoothConnected
|
return .bluetoothConnected
|
||||||
} else if isReachable {
|
|
||||||
return .meshReachable
|
|
||||||
} else if favoriteStatus?.isMutual == true {
|
} else if favoriteStatus?.isMutual == true {
|
||||||
// Mutual favorites can communicate via Nostr when offline
|
// Mutual favorites can communicate via Nostr when offline
|
||||||
return .nostrAvailable
|
return .nostrAvailable
|
||||||
@@ -58,8 +54,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
switch connectionState {
|
switch connectionState {
|
||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
return "📻" // Radio icon for mesh connection
|
return "📻" // Radio icon for mesh connection
|
||||||
case .meshReachable:
|
|
||||||
return "📡" // Antenna for mesh reachable
|
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
return "🌐" // Purple globe for Nostr
|
return "🌐" // Purple globe for Nostr
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -77,15 +71,13 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false
|
||||||
isReachable: Bool = false
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
|
||||||
|
|
||||||
// Load favorite status - will be set later by the manager
|
// Load favorite status - will be set later by the manager
|
||||||
self.favoriteStatus = nil
|
self.favoriteStatus = nil
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class GeoRelayDirectory {
|
|||||||
private let cacheFileName = "georelays_cache.csv"
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Load cached or bundled data synchronously
|
// Load cached or bundled data synchronously
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ struct NostrProtocol {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let signingKey = try senderIdentity.signingKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
@@ -149,8 +149,9 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the seal with the sender's Schnorr private key
|
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||||
return try seal.sign(with: senderKey)
|
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
||||||
|
return try seal.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
@@ -180,8 +181,9 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the gift wrap with the wrap Schnorr private key
|
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||||
return try giftWrap.sign(with: wrapKey)
|
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
||||||
|
return try giftWrap.sign(with: signingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func unwrapGiftWrap(
|
||||||
@@ -470,16 +472,19 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
// Sign with Schnorr (BIP-340)
|
// Convert to Schnorr key for Nostr signing
|
||||||
|
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
|
||||||
|
|
||||||
|
// Sign with Schnorr
|
||||||
var messageBytes = [UInt8](eventIdHash)
|
var messageBytes = [UInt8](eventIdHash)
|
||||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
}
|
}
|
||||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject {
|
|||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
|
||||||
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
|
||||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
private let backoffMultiplier: Double = 2.0 // Double each time
|
||||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
private let maxReconnectAttempts = 10 // Stop after 10 attempts
|
||||||
|
|
||||||
// Reconnection timer
|
// Reconnection timer
|
||||||
private var reconnectionTimer: Timer?
|
private var reconnectionTimer: Timer?
|
||||||
@@ -289,7 +289,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Only log non-gift-wrap events to reduce noise
|
// Only log non-gift-wrap events to reduce noise
|
||||||
if event.kind != 1059 {
|
if event.kind != 1059 {
|
||||||
SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
|
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
|
||||||
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let data = try encoder.encode(req)
|
let data = try encoder.encode(req)
|
||||||
let message = String(data: data, encoding: .utf8) ?? ""
|
let message = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
|
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
connection.send(.string(message)) { [weak self] error in
|
connection.send(.string(message)) { [weak self] error in
|
||||||
@@ -568,7 +568,7 @@ struct NostrFilter: Encodable {
|
|||||||
filter.kinds = [1059] // Gift wrap kind
|
filter.kinds = [1059] // Gift wrap kind
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
filter.limit = 100 // Add a reasonable limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,28 +87,4 @@ enum Geohash {
|
|||||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
return (lat, lon)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-459
File diff suppressed because it is too large
Load Diff
@@ -1,227 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
super.init()
|
super.init()
|
||||||
cl.delegate = self
|
cl.delegate = self
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
cl.distanceFilter = 1000 // meters; we're not tracking continuously
|
||||||
// Load selection
|
// Load selection
|
||||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
@@ -50,8 +50,10 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
teleportedSet = Set(arr)
|
teleportedSet = Set(arr)
|
||||||
}
|
}
|
||||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
// Initialize teleported flag from persisted state if a location channel is selected
|
||||||
// This avoids showing teleported for in-region channels during cold start.
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
@@ -59,15 +61,6 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
status = CLLocationManager.authorizationStatus()
|
status = CLLocationManager.authorizationStatus()
|
||||||
}
|
}
|
||||||
updatePermissionState(from: status)
|
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
|
// MARK: - Public API
|
||||||
@@ -99,30 +92,23 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
|
||||||
guard permissionState == .authorized else { return }
|
guard permissionState == .authorized else { return }
|
||||||
// Stop any previous polling timer
|
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
// Tighten accuracy and distance filter for live view
|
self?.requestOneShotLocation()
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
}
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
// Kick off immediately
|
||||||
// Start continuous updates
|
|
||||||
cl.startUpdatingLocation()
|
|
||||||
// Request an immediate fix to populate UI without waiting for movement
|
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop continuous refreshes when selector UI is dismissed.
|
/// Stop periodic refreshes when selector UI is dismissed.
|
||||||
func endLiveRefresh() {
|
func endLiveRefresh() {
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = nil
|
refreshTimer = nil
|
||||||
cl.stopUpdatingLocation()
|
cl.stopUpdatingLocation()
|
||||||
// Restore more relaxed defaults for background/idle state
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(_ channel: ChannelID) {
|
func select(_ channel: ChannelID) {
|
||||||
@@ -136,24 +122,10 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
case .mesh:
|
case .mesh:
|
||||||
self.teleported = false
|
self.teleported = false
|
||||||
case .location(let ch):
|
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)
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
@@ -223,25 +195,14 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.availableChannels = result
|
self.availableChannels = result
|
||||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||||
switch self.selectedChannel {
|
switch self.selectedChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
self.teleported = false
|
self.teleported = false
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||||
if inRegional {
|
self.teleported = persisted || (currentGH != ch.geohash)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let hasMesh = mesh.isPeerConnected(peerID)
|
||||||
if reachableMesh {
|
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
||||||
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
if hasMesh && hasEstablished {
|
||||||
|
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
@@ -57,9 +57,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||||
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -70,9 +70,7 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||||
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
@@ -103,7 +101,7 @@ final class MessageRouter {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
var myPeerID: String { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
@@ -31,7 +31,6 @@ final class NostrTransport: Transport {
|
|||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: String) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [String : String] { [:] }
|
||||||
|
|
||||||
@@ -49,7 +48,16 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
// Resolve favorite by full noise key or by short peerID fallback
|
||||||
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
@@ -97,7 +105,15 @@ final class NostrTransport: Transport {
|
|||||||
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: item.peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, item.peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
@@ -133,7 +149,15 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
||||||
@@ -159,26 +183,18 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
@MainActor
|
|
||||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
|
||||||
if let noiseKey = Data(hexString: peerID),
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
||||||
let npub = fav.peerNostrPublicKey {
|
|
||||||
return npub
|
|
||||||
}
|
|
||||||
if peerID.count == 16,
|
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
|
||||||
let npub = fav.peerNostrPublicKey {
|
|
||||||
return npub
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
var recipientNostrPubkey: String?
|
||||||
|
if let noiseKey = Data(hexString: peerID),
|
||||||
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||||
|
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||||
|
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|||||||
@@ -90,15 +90,6 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geohash public chat notification with deep link to a specific geohash
|
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
|
||||||
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
|
||||||
let deeplink = "bitchat://geohash/\(geohash)"
|
|
||||||
let userInfo: [String: Any] = ["deeplink": deeplink]
|
|
||||||
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = 1337
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: String) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ struct RelayController {
|
|||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
@@ -26,8 +25,7 @@ struct RelayController {
|
|||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = (ttl &- 1)
|
let newTTL = (ttl &- 1)
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
|
||||||
let delayMs = Int.random(in: delayRange)
|
let delayMs = Int.random(in: delayRange)
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
@@ -44,22 +42,15 @@ struct RelayController {
|
|||||||
let prob = baseProb
|
let prob = baseProb
|
||||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping in dense graphs (only for broadcast)
|
||||||
// - Dense graphs: keep very low to avoid floods
|
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
|
||||||
// - Announces in sparse graphs get a bit more headroom
|
|
||||||
let ttlCap: UInt8 = {
|
|
||||||
if degree >= highDegreeThreshold { return 3 }
|
|
||||||
return isAnnounce ? 7 : 6
|
|
||||||
}()
|
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let clamped = max(1, min(ttl, ttlCap))
|
||||||
let newTTL = clamped &- 1
|
let newTTL = clamped &- 1
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
|
||||||
let delayMs: Int
|
let delayMs: Int
|
||||||
switch degree {
|
switch degree {
|
||||||
case 0...2: delayMs = Int.random(in: 10...40)
|
case 0...2: delayMs = Int.random(in: 40...100)
|
||||||
case 3...5: delayMs = Int.random(in: 60...150)
|
case 3...5: delayMs = Int.random(in: 60...150)
|
||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Combine
|
|||||||
|
|
||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot {
|
||||||
let id: String
|
let id: String
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
@@ -29,7 +29,6 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: String) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: String) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [String: String]
|
||||||
|
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Centralized knobs for transport- and UI-related limits.
|
|
||||||
/// Keep values aligned with existing behavior when replacing magic numbers.
|
|
||||||
enum TransportConfig {
|
|
||||||
// BLE / Protocol
|
|
||||||
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
|
||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
|
||||||
|
|
||||||
// UI / Storage Caps
|
|
||||||
static let privateChatCap: Int = 1337
|
|
||||||
static let meshTimelineCap: Int = 1337
|
|
||||||
static let geoTimelineCap: Int = 1337
|
|
||||||
static let contentLRUCap: Int = 2000
|
|
||||||
|
|
||||||
// Timers
|
|
||||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
|
||||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
|
||||||
|
|
||||||
// BLE duty/announce/connect
|
|
||||||
static let bleConnectRateLimitInterval: TimeInterval = 0.5
|
|
||||||
static let bleMaxCentralLinks: Int = 6
|
|
||||||
static let bleDutyOnDuration: TimeInterval = 5.0
|
|
||||||
static let bleDutyOffDuration: TimeInterval = 10.0
|
|
||||||
static let bleAnnounceMinInterval: TimeInterval = 1.0
|
|
||||||
|
|
||||||
// BLE discovery/quality thresholds
|
|
||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
|
||||||
static let blePendingNotificationsCapCount: Int = 20
|
|
||||||
|
|
||||||
// Nostr
|
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
|
||||||
|
|
||||||
// UI thresholds
|
|
||||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
|
||||||
|
|
||||||
// UI rate limiters (token buckets)
|
|
||||||
static let uiSenderRateBucketCapacity: Double = 5
|
|
||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
|
||||||
|
|
||||||
// UI sleeps/delays
|
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
|
||||||
static let uiStartupShortSleepNs: UInt64 = 200_000_000
|
|
||||||
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
|
||||||
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
|
|
||||||
static let uiAsyncMediumSleepNs: UInt64 = 500_000_000
|
|
||||||
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
|
||||||
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
|
||||||
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
|
||||||
static let uiScrollThrottleSeconds: TimeInterval = 0.5
|
|
||||||
static let uiAnimationShortSeconds: TimeInterval = 0.15
|
|
||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
|
||||||
static let bleMaintenanceInterval: TimeInterval = 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,28 +68,24 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func updatePeers() {
|
private func updatePeers() {
|
||||||
let meshPeers = meshService.currentPeerSnapshots()
|
let meshPeers = meshService.currentPeerSnapshots()
|
||||||
// If we have no direct links at all, peers should not be marked reachable
|
|
||||||
// "Reachable" means mesh-attached via at least one live link.
|
|
||||||
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
|
||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<String> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<String> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all connected mesh peers
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers where peerInfo.isConnected {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.id
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
peerInfo: peerInfo,
|
peerInfo: peerInfo,
|
||||||
favorites: favorites,
|
favorites: favorites
|
||||||
meshAttached: hasAnyConnected
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
if peer.isConnected { connected.insert(peerID) }
|
connected.insert(peerID)
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
@@ -121,12 +117,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 3: Sort peers
|
// Phase 3: Sort peers
|
||||||
enrichedPeers.sort { lhs, rhs in
|
enrichedPeers.sort { lhs, rhs in
|
||||||
// Connectivity rank: connected > reachable > others
|
// Connected first
|
||||||
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
if lhs.isConnected != rhs.isConnected {
|
||||||
let lr = rank(lhs), rr = rank(rhs)
|
return lhs.isConnected
|
||||||
if lr != rr { return lr > rr }
|
}
|
||||||
// Then favorites inside same rank
|
// Then favorites
|
||||||
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
if lhs.isFavorite != rhs.isFavorite {
|
||||||
|
return lhs.isFavorite
|
||||||
|
}
|
||||||
// Finally alphabetical
|
// Finally alphabetical
|
||||||
return lhs.displayName < rhs.displayName
|
return lhs.displayName < rhs.displayName
|
||||||
}
|
}
|
||||||
@@ -147,11 +145,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Filter out offline non-mutual peers and update published properties
|
// Phase 5: Update published properties
|
||||||
let filtered = enrichedPeers.filter { p in
|
self.peers = enrichedPeers
|
||||||
p.isConnected || p.isReachable || p.isMutualFavorite
|
|
||||||
}
|
|
||||||
self.peers = filtered
|
|
||||||
self.connectedPeerIDs = connected
|
self.connectedPeerIDs = connected
|
||||||
self.favorites = favoritesList
|
self.favorites = favoritesList
|
||||||
self.mutualFavorites = mutualsList
|
self.mutualFavorites = mutualsList
|
||||||
@@ -167,26 +162,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromMesh(
|
private func buildPeerFromMesh(
|
||||||
peerInfo: TransportPeerSnapshot,
|
peerInfo: TransportPeerSnapshot,
|
||||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||||
meshAttached: Bool
|
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
// Determine reachability based on lastSeen and identity trust
|
|
||||||
let now = Date()
|
|
||||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
|
||||||
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
|
||||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
|
||||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
|
||||||
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
|
||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
id: peerInfo.id,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
isConnected: peerInfo.isConnected,
|
isConnected: true
|
||||||
isReachable: isReachable
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for favorite status
|
// Check for favorite status
|
||||||
@@ -233,8 +216,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
isConnected: false,
|
isConnected: false
|
||||||
isReachable: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
peer.favoriteStatus = favorite
|
peer.favoriteStatus = favorite
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ final class VerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||||
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? {
|
||||||
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||||
// Freshness
|
// Freshness
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Compression
|
|||||||
|
|
||||||
struct CompressionUtil {
|
struct CompressionUtil {
|
||||||
// Compression threshold - don't compress if data is smaller than this
|
// Compression threshold - don't compress if data is smaller than this
|
||||||
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
|
static let compressionThreshold = 100 // bytes
|
||||||
|
|
||||||
// Compress data using zlib algorithm (most compatible)
|
// Compress data using zlib algorithm (most compatible)
|
||||||
static func compress(_ data: Data) -> Data? {
|
static func compress(_ data: Data) -> Data? {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ final class MessageDeduplicator {
|
|||||||
private var entries: [Entry] = []
|
private var entries: [Entry] = []
|
||||||
private var lookup = Set<String>()
|
private var lookup = Set<String>()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
|
private let maxAge: TimeInterval = 300 // 5 minutes
|
||||||
private let maxCount = TransportConfig.messageDedupMaxCount
|
private let maxCount = 1000
|
||||||
|
|
||||||
/// Check if message is duplicate and add if not
|
/// Check if message is duplicate and add if not
|
||||||
func isDuplicate(_ messageID: String) -> Bool {
|
func isDuplicate(_ messageID: String) -> Bool {
|
||||||
@@ -84,3 +84,4 @@ final class MessageDeduplicator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
|
|
||||||
struct PeerDisplayNameResolver {
|
|
||||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
|
||||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
|
||||||
/// - Returns: Map of peerID -> displayName.
|
|
||||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
|
||||||
// Count collisions among connected peers and include our own nickname
|
|
||||||
var counts: [String: Int] = [:]
|
|
||||||
for p in peers where p.isConnected {
|
|
||||||
counts[p.nickname, default: 0] += 1
|
|
||||||
}
|
|
||||||
counts[selfNickname, default: 0] += 1
|
|
||||||
|
|
||||||
var result: [String: String] = [:]
|
|
||||||
for p in peers {
|
|
||||||
var name = p.nickname
|
|
||||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
|
||||||
name += "#" + String(p.id.prefix(4))
|
|
||||||
}
|
|
||||||
result[p.id] = name
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@@ -101,14 +100,14 @@ struct ContentView: View {
|
|||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
if translation > 50 || (translation > 30 && velocity > 300) {
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
backSwipeOffset = 0
|
backSwipeOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,7 @@ struct ContentView: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -152,14 +151,14 @@ struct ContentView: View {
|
|||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
||||||
return showSidebar ? -dragOffset : width - dragOffset
|
return showSidebar ? -dragOffset : width - dragOffset
|
||||||
}())
|
}())
|
||||||
.animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
|
.animation(.easeInOut(duration: 0.25), value: showSidebar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showPrivateChat = newValue != nil
|
showPrivateChat = newValue != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +195,7 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -265,7 +264,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Implement windowing with adjustable window count per chat
|
// Implement windowing with adjustable window count per chat
|
||||||
let currentWindowCount: Int = {
|
let currentWindowCount: Int = {
|
||||||
if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate }
|
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
|
||||||
return windowCountPublic
|
return windowCountPublic
|
||||||
}()
|
}()
|
||||||
let windowedMessages = messages.suffix(currentWindowCount)
|
let windowedMessages = messages.suffix(currentWindowCount)
|
||||||
@@ -297,11 +296,11 @@ struct ContentView: View {
|
|||||||
let cashuTokens = message.content.extractCashuTokens()
|
let cashuTokens = message.content.extractCashuTokens()
|
||||||
let lightningLinks = message.content.extractLightningLinks()
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
.lineLimit(isLong && !isExpanded ? 30 : nil)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
@@ -313,7 +312,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
|
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Button(isExpanded ? "show less" : "show more") {
|
Button(isExpanded ? "show less" : "show more") {
|
||||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
@@ -372,7 +371,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
// Infinite scroll up: when top row appears, increase window and preserve anchor
|
||||||
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
|
||||||
let step = TransportConfig.uiWindowStepCount
|
let step = 200
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
if let peer = privatePeer { return "dm:\(peer)" }
|
if let peer = privatePeer { return "dm:\(peer)" }
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -382,7 +381,7 @@ struct ContentView: View {
|
|||||||
}()
|
}()
|
||||||
let preserveID = "\(contextKey)|\(message.id)"
|
let preserveID = "\(contextKey)|\(message.id)"
|
||||||
if let peer = privatePeer {
|
if let peer = privatePeer {
|
||||||
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
let current = windowCountPrivate[peer] ?? 300
|
||||||
let newCount = min(messages.count, current + step)
|
let newCount = min(messages.count, current + step)
|
||||||
if newCount != current {
|
if newCount != current {
|
||||||
windowCountPrivate[peer] = newCount
|
windowCountPrivate[peer] = newCount
|
||||||
@@ -460,13 +459,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
// Do not mark teleported when opening a geohash that is in our regional set.
|
|
||||||
// If availableChannels is empty (e.g., cold start), defer marking and let
|
|
||||||
// LocationChannelManager compute teleported based on actual location.
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
|
||||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
|
||||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
}
|
|
||||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
@@ -547,7 +540,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Throttle scroll animations to prevent excessive UI updates
|
// Throttle scroll animations to prevent excessive UI updates
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
// Immediate scroll if enough time has passed
|
// Immediate scroll if enough time has passed
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
@@ -564,7 +557,7 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Schedule a delayed scroll
|
// Schedule a delayed scroll
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
let contextKey: String = {
|
let contextKey: String = {
|
||||||
switch locationManager.selectedChannel {
|
switch locationManager.selectedChannel {
|
||||||
@@ -596,7 +589,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
// Same throttling for private chats
|
// Same throttling for private chats
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey = "dm:\(peerID)"
|
let contextKey = "dm:\(peerID)"
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
@@ -606,7 +599,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTimer?.invalidate()
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
||||||
lastScrollTime = Date()
|
lastScrollTime = Date()
|
||||||
let contextKey = "dm:\(peerID)"
|
let contextKey = "dm:\(peerID)"
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
@@ -626,7 +619,7 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Reset window size
|
// Reset window size
|
||||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
windowCountPublic = 300
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
let contextKey = "geo:\(ch.geohash)"
|
||||||
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
||||||
let target = last.map { "\(contextKey)|\($0)" }
|
let target = last.map { "\(contextKey)|\($0)" }
|
||||||
@@ -642,11 +635,11 @@ struct ContentView: View {
|
|||||||
// Try multiple times to ensure read receipts are sent
|
// Try multiple times to ensure read receipts are sent
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,7 +859,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Delay keyboard focus to avoid iOS constraint warnings
|
// Delay keyboard focus to avoid iOS constraint warnings
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,7 +925,7 @@ struct ContentView: View {
|
|||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
onTapPeer: { peerID in
|
onTapPeer: { peerID in
|
||||||
viewModel.startPrivateChat(with: peerID)
|
viewModel.startPrivateChat(with: peerID)
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar = false
|
showSidebar = false
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -980,7 +973,7 @@ struct ContentView: View {
|
|||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
if !showSidebar {
|
if !showSidebar {
|
||||||
if translation < -100 || (translation < -50 && velocity < -500) {
|
if translation < -100 || (translation < -50 && velocity < -500) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -1043,8 +1036,9 @@ struct ContentView: View {
|
|||||||
case .mesh:
|
case .mesh:
|
||||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||||
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
let isMeshConnected = peer.isConnected
|
||||||
else if peer.isReachable { counts.others += 1 }
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
}
|
}
|
||||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
||||||
@@ -1119,15 +1113,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Open unread private chat")
|
.accessibilityLabel("Open unread private chat")
|
||||||
}
|
}
|
||||||
// Bookmark toggle for current geohash (not shown for mesh)
|
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
|
||||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
|
||||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
|
||||||
}
|
|
||||||
// Location channels button '#'
|
// Location channels button '#'
|
||||||
Button(action: { showLocationChannelsSheet = true }) {
|
Button(action: { showLocationChannelsSheet = true }) {
|
||||||
let badgeText: String = {
|
let badgeText: String = {
|
||||||
@@ -1168,7 +1153,7 @@ struct ContentView: View {
|
|||||||
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showSidebar.toggle()
|
showSidebar.toggle()
|
||||||
sidebarDragOffset = 0
|
sidebarDragOffset = 0
|
||||||
}
|
}
|
||||||
@@ -1196,11 +1181,20 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||||
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
||||||
let headerPeerID: String = {
|
let headerPeerID: String = {
|
||||||
if privatePeerID.count == 64 {
|
if privatePeerID.count == 16 {
|
||||||
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
|
||||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
||||||
|
return stable
|
||||||
|
}
|
||||||
|
} else if privatePeerID.count == 64 {
|
||||||
|
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
|
||||||
|
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return privatePeerID
|
return privatePeerID
|
||||||
}()
|
}()
|
||||||
@@ -1215,29 +1209,10 @@ struct ContentView: View {
|
|||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try mesh/unified peer display
|
return peer?.displayName ??
|
||||||
if let name = peer?.displayName { return name }
|
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||||
// Try direct mesh nickname (connected-only)
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
"Unknown"
|
||||||
// Try favorite nickname by stable Noise key
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
||||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
|
||||||
if headerPeerID.count == 16 {
|
|
||||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
|
||||||
if let id = candidates.first,
|
|
||||||
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
|
||||||
let fp = keyData.sha256Fingerprint()
|
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}()
|
}()
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
@@ -1272,12 +1247,6 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
case .meshReachable:
|
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel("Reachable via mesh")
|
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1288,12 +1257,6 @@ struct ContentView: View {
|
|||||||
// Should not happen for PM header, but handle gracefully
|
// Should not happen for PM header, but handle gracefully
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
|
||||||
// Fallback: reachable via mesh but not in current peer list
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel("Reachable via mesh")
|
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1312,14 +1275,7 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
let statusPeerID: String = {
|
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
@@ -1338,7 +1294,7 @@ struct ContentView: View {
|
|||||||
// Left and right buttons positioned with HStack
|
// Left and right buttons positioned with HStack
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
showPrivateChat = false
|
showPrivateChat = false
|
||||||
viewModel.endPrivateChat()
|
viewModel.endPrivateChat()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,27 +41,9 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Peer info
|
||||||
let statusPeerID: String = {
|
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
// Resolve a friendly name
|
|
||||||
let peerNickname: String = {
|
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
||||||
let fp = data.sha256Fingerprint()
|
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}()
|
|
||||||
// Accurate encryption state based on short ID session
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -92,7 +74,7 @@ struct FingerprintView: View {
|
|||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import AppKit
|
|||||||
struct LocationChannelsSheet: View {
|
struct LocationChannelsSheet: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@@ -84,22 +83,29 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
// Begin periodic refresh while sheet is open
|
// Begin periodic refresh while sheet is open
|
||||||
manager.beginLiveRefresh()
|
manager.beginLiveRefresh()
|
||||||
// Geohash sampling is now managed by ChatViewModel globally
|
// Begin multi-channel sampling for counts
|
||||||
|
let ghs = manager.availableChannels.map { $0.geohash }
|
||||||
|
viewModel.beginGeohashSampling(for: ghs)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
manager.endLiveRefresh()
|
manager.endLiveRefresh()
|
||||||
|
viewModel.endGeohashSampling()
|
||||||
}
|
}
|
||||||
.onChange(of: manager.permissionState) { newValue in
|
.onChange(of: manager.permissionState) { newValue in
|
||||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||||
manager.refreshChannels()
|
manager.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: manager.availableChannels) { _ in }
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
List {
|
||||||
// Mesh option first (no bookmark)
|
// Mesh option first
|
||||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
@@ -113,21 +119,7 @@ struct LocationChannelsSheet: View {
|
|||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||||
channelRow(
|
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||||
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.
|
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
manager.markTeleported(for: channel.geohash, false)
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
@@ -203,48 +195,6 @@ 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
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -277,19 +227,9 @@ struct LocationChannelsSheet: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||||
private func channelRow(
|
Button(action: action) {
|
||||||
title: String,
|
HStack {
|
||||||
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) {
|
VStack(alignment: .leading) {
|
||||||
// Render title with smaller font for trailing count in parentheses
|
// Render title with smaller font for trailing count in parentheses
|
||||||
let parts = splitTitleAndCount(title)
|
let parts = splitTitleAndCount(title)
|
||||||
@@ -316,8 +256,6 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,11 +265,11 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.system(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
trailingAccessory()
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(perform: action)
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||||
@@ -351,28 +289,20 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func meshCount() -> Int {
|
private func meshCount() -> Int {
|
||||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||||
let noun = count == 1 ? "person" : "people"
|
let noun = count == 1 ? "person" : "people"
|
||||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
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 {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ struct MeshPeerList: View {
|
|||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.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 {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -115,14 +110,6 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Unread message indicator for this peer
|
|
||||||
if !isMe, item.hasUnread {
|
|
||||||
Image(systemName: "envelope.fill")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.help("New messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
|
|||||||
@@ -153,13 +153,13 @@ final class ShareViewController: UIViewController {
|
|||||||
userDefaults.set(content, forKey: "sharedContent")
|
userDefaults.set(content, forKey: "sharedContent")
|
||||||
userDefaults.set(type, forKey: "sharedContentType")
|
userDefaults.set(type, forKey: "sharedContentType")
|
||||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||||
// No need to force synchronize; the system persists changes
|
userDefaults.synchronize()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishWithMessage(_ msg: String) {
|
private func finishWithMessage(_ msg: String) {
|
||||||
statusLabel.text = msg
|
statusLabel.text = msg
|
||||||
// Complete shortly after showing status
|
// Complete shortly after showing status
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user