Compare commits

...
Author SHA1 Message Date
jack acc5bdf5ec Fix: move ParsedInbound + parseInboundMessage to file scope (non-isolated) to satisfy Swift 6; update detached tasks to call free function 2025-09-05 11:07:11 +02:00
jack fcd82c28c6 Fix: Swift 6 concurrency - avoid capturing self in detached tasks; deliver parsed Nostr messages via MainActor singleton 2025-09-05 10:57:01 +02:00
jack 4e51c83914 Nostr: parse inbound messages off-main, then update state on main; BLE: debounce peer snapshot publishing to reduce churn 2025-09-04 23:52:05 +02:00
jack d9884eaf87 Compression: revert to zlib per request (compatibility) 2025-09-04 23:18:26 +02:00
jack a486644b7f Perf: reduce hot‑path overhead (logger autoclosure, zero‑copy BinaryProtocol.decode, prealloc encoders) 2025-09-04 23:17:00 +02:00
5273f13512 Fix critical routing and buffer issues\n\n- Preserve unsent items in MessageRouter outbox (no data loss)\n- Drain and bound Nostr send queue; flush on relay connect\n- Cap BLE pending write buffers per peripheral to avoid OOM\n- Enable Nostr fallback for short peer IDs via favorites mapping\n- Route favorite notifications via MessageRouter (mesh/Nostr) (#559)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-04 21:26:58 +02:00
e79bcf531b Feature/screenshot privacy warning (#541)
* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 14:37:17 +02:00
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
b63a595b04 Fix geohash UX: rising-edge notify, correct teleport, sort timeline (#537)
- Remove "new chats!" background nudge; notify only when a regional geohash goes from 0 participants to activity, with content preview and cooldown.\n- Do not mark teleported when selected geohash is in regional channel list; clear persisted teleport when in-region; avoid self teleported state from historical tags; deep links don’t mark teleported for regional geohashes or during cold start.\n- Sort per-geohash timelines chronologically on re-entry and trim whitespace-only content to prevent blank lines.\n- Trim inbound public content and ignore whitespace-only sends.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 00:08:01 +01:00
d7b7f1f673 UI: diversify peer colors; smarter geo notifications; 21m live location (#531)
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 18:43:22 +02:00
JDandGitHub 7aa3622349 Update README to explain hybrid messaging architecture (#530) 2025-08-26 18:42:21 +02:00
21 changed files with 1629 additions and 474 deletions
+66 -16
View File
@@ -2,7 +2,7 @@
## bitchat ## bitchat
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat. A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
[bitchat.free](http://bitchat.free) [bitchat.free](http://bitchat.free)
@@ -11,50 +11,99 @@ A decentralized peer-to-peer messaging app that works over Bluetooth mesh networ
> [!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) - **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **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)
### Binary Protocol BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
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
### Mesh Networking ### Bluetooth Mesh Network (Offline)
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management - **Local Communication**: Direct peer-to-peer within Bluetooth range
- Adaptive duty cycling for battery optimization - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
- **No Internet Required**: Works completely offline in disaster scenarios
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
- **Automatic Discovery**: Peer discovery and connection management
- **Adaptive Power**: Battery-optimized duty cycling
### Nostr Protocol (Internet)
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
### Channel Types
#### `mesh #bluetooth`
- **Transport**: Bluetooth Low Energy mesh network
- **Scope**: Local devices within multi-hop range
- **Internet**: Not required
- **Use Case**: Offline communication, protests, disasters, remote areas
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
- **Transport**: Nostr protocol over internet
- **Scope**: Geographic areas defined by geohash precision
- `block` (7 chars): City block level
- `neighborhood` (6 chars): District/neighborhood
- `city` (5 chars): City level
- `province` (4 chars): State/province
- `region` (2 chars): Country/large region
- **Internet**: Required (connects to Nostr relays)
- **Use Case**: Location-based community chat, local events, regional discussions
### Direct Message Routing
Private messages use **intelligent transport selection**:
1. **Bluetooth First** (preferred when available)
- Direct connection with established Noise session
- Fastest and most private option
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- Routes through global relay network
3. **Smart Queuing** (when neither available)
- Messages queued until transport becomes available
- Automatic delivery when connection established
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md). 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
@@ -68,6 +117,7 @@ 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
+12
View File
@@ -32,6 +32,10 @@
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* 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 */; }; 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 */; };
@@ -205,6 +209,8 @@
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>"; }; 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>"; };
@@ -483,6 +489,7 @@
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 */,
@@ -519,6 +526,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
@@ -771,6 +779,7 @@
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 */,
@@ -830,6 +839,7 @@
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 */,
@@ -866,6 +876,7 @@
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 */,
@@ -888,6 +899,7 @@
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 */,
+17
View File
@@ -173,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
} }
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler() completionHandler()
} }
@@ -192,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
} }
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases // Show notification in all other cases
completionHandler([.banner, .sound]) completionHandler([.banner, .sound])
+131 -66
View File
@@ -44,8 +44,15 @@ class NostrRelayManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Message queue for reliability // Message queue for reliability
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = [] // Pending sends held only for relays that are not yet connected.
private struct PendingSend {
var event: NostrEvent
var pendingRelays: Set<String>
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -59,6 +66,8 @@ class NostrRelayManager: ObservableObject {
init() { init() {
// Initialize with default relays // Initialize with default relays
self.relays = Self.defaultRelays.map { Relay(url: $0) } self.relays = Self.defaultRelays.map { Relay(url: $0) }
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
} }
/// Connect to all configured relays /// Connect to all configured relays
@@ -96,15 +105,56 @@ class NostrRelayManager: ObservableObject {
let targetRelays = relayUrls ?? Self.defaultRelays let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Add to queue for reliability // Attempt immediate send to relays with active connections; queue the rest
messageQueueLock.lock() var stillPending = Set<String>()
messageQueue.append((event, targetRelays))
messageQueueLock.unlock()
// Attempt immediate send
for relayUrl in targetRelays { for relayUrl in targetRelays {
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
/// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
guard !messageQueue.isEmpty else { return }
if let target = relayUrl {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
}
}
} else {
// Flush for any relays that now have connections
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
}
if item.pendingRelays.isEmpty {
messageQueue.remove(at: i)
} else {
messageQueue[i] = item
}
} }
} }
} }
@@ -124,8 +174,6 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.subscribe(id: id, filters: [filter]) let req = NostrRequest.subscribe(id: id, filters: [filter])
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
@@ -175,7 +223,7 @@ class NostrRelayManager: ObservableObject {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? JSONEncoder().encode(req) let message = try? encoder.encode(req)
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -242,13 +290,20 @@ class NostrRelayManager: ObservableObject {
case .success(let message): case .success(let message):
switch message { switch message {
case .string(let text): case .string(let text):
Task { @MainActor in // Parse off-main to reduce UI jank, then hop back for state updates
self.handleMessage(text, from: relayUrl) Task.detached(priority: .utility) {
guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
case .data(let data): case .data(let data):
if let text = String(data: data, encoding: .utf8) { if let text = String(data: data, encoding: .utf8) {
Task { @MainActor in Task.detached(priority: .utility) {
self.handleMessage(text, from: relayUrl) guard let parsed = parseInboundMessage(text) else { return }
await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
}
} }
} }
@unknown default: @unknown default:
@@ -268,57 +323,31 @@ class NostrRelayManager: ObservableObject {
} }
} }
private func handleMessage(_ message: String, from relayUrl: String) { // Parsed inbound message type (off-main)
guard let data = message.data(using: .utf8) else { return } // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
do { // Handle parsed message on MainActor (state updates and handlers)
// Try to decode as an array first private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any], switch parsed {
array.count >= 2, case .event(let subId, let event):
let type = array[0] as? String {
// Received message from relay
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
// 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("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
DispatchQueue.main.async {
// Update relay stats
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1 self.relays[index].messagesReceived += 1
} }
// Call handler
if let handler = self.messageHandlers[subId] { if let handler = self.messageHandlers[subId] {
handler(event) handler(event)
} else { } else {
SecureLogger.log("⚠️ No handler for subscription \(subId)", SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning) category: SecureLogger.session, level: .warning)
} }
} case .eose:
} // No-op for now
break
case "EOSE": case .ok(let eventId, let success, let reason):
if array.count >= 2 {
// End of stored events
}
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
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("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
@@ -328,19 +357,8 @@ class NostrRelayManager: ObservableObject {
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
} }
} case .notice:
break
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
}
}
} catch {
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
} }
} }
@@ -348,8 +366,6 @@ class NostrRelayManager: ObservableObject {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
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) ?? ""
@@ -389,6 +405,10 @@ class NostrRelayManager: ObservableObject {
} }
} }
updateConnectionStatus() updateConnectionStatus()
// If we just connected to this relay, flush any queued sends targeting it
if isConnected {
flushMessageQueue(for: url)
}
} }
private func updateConnectionStatus() { private func updateConnectionStatus() {
@@ -494,6 +514,51 @@ class NostrRelayManager: ObservableObject {
} }
} }
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
}
// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated
private func parseInboundMessage(_ message: String) -> ParsedInbound? {
guard let data = message.data(using: .utf8) else { return nil }
do {
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String {
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any] {
let event = try NostrEvent(from: eventDict)
return .event(subId: subId, event: event)
}
case "EOSE":
if let subId = array[1] as? String { return .eose(subscriptionId: subId) }
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
return .ok(eventId: eventId, success: success, reason: reason)
}
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String { return .notice(msg) }
default:
return nil
}
}
} catch {
// Ignore
}
return nil
}
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
enum NostrRequest: Encodable { enum NostrRequest: Encodable {
+8 -9
View File
@@ -40,23 +40,23 @@ extension Data {
extension Data { extension Data {
// MARK: Writing // MARK: Writing
mutating func appendUInt8(_ value: UInt8) { @inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
mutating func appendUInt16(_ value: UInt16) { @inlinable mutating func appendUInt16(_ value: UInt16) {
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
mutating func appendUInt32(_ value: UInt32) { @inlinable mutating func appendUInt32(_ value: UInt32) {
self.append(UInt8((value >> 24) & 0xFF)) self.append(UInt8((value >> 24) & 0xFF))
self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF))
self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF))
self.append(UInt8(value & 0xFF)) self.append(UInt8(value & 0xFF))
} }
mutating func appendUInt64(_ value: UInt64) { @inlinable mutating func appendUInt64(_ value: UInt64) {
for i in (0..<8).reversed() { for i in (0..<8).reversed() {
self.append(UInt8((value >> (i * 8)) & 0xFF)) self.append(UInt8((value >> (i * 8)) & 0xFF))
} }
@@ -113,21 +113,21 @@ extension Data {
// MARK: Reading // MARK: Reading
func readUInt8(at offset: inout Int) -> UInt8? { @inlinable func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
return value return value
} }
func readUInt16(at offset: inout Int) -> UInt16? { @inlinable func readUInt16(at offset: inout Int) -> UInt16? {
guard offset + 2 <= self.count else { return nil } guard offset + 2 <= self.count else { return nil }
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
offset += 2 offset += 2
return value return value
} }
func readUInt32(at offset: inout Int) -> UInt32? { @inlinable func readUInt32(at offset: inout Int) -> UInt32? {
guard offset + 4 <= self.count else { return nil } guard offset + 4 <= self.count else { return nil }
let value = UInt32(self[offset]) << 24 | let value = UInt32(self[offset]) << 24 |
UInt32(self[offset + 1]) << 16 | UInt32(self[offset + 1]) << 16 |
@@ -137,7 +137,7 @@ extension Data {
return value return value
} }
func readUInt64(at offset: inout Int) -> UInt64? { @inlinable func readUInt64(at offset: inout Int) -> UInt64? {
guard offset + 8 <= self.count else { return nil } guard offset + 8 <= self.count else { return nil }
var value: UInt64 = 0 var value: UInt64 = 0
for i in 0..<8 { for i in 0..<8 {
@@ -220,4 +220,3 @@ extension Data {
return data return data
} }
} }
+70 -95
View File
@@ -139,6 +139,11 @@ struct BinaryProtocol {
} }
// Header // Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version) data.append(packet.version)
data.append(packet.type) data.append(packet.type)
data.append(packet.ttl) data.append(packet.ttl)
@@ -222,136 +227,106 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size check: header + senderID // Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { guard raw.count >= headerSize + senderIDSize else { return nil }
return nil
}
// Convert to array for safer indexed access return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
let dataArray = Array(raw) guard let base = buf.baseAddress else { return nil }
var offset = 0 var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Header parsing with bounds checks // Read single byte
guard offset < dataArray.count else { return nil } func read8() -> UInt8? {
let version = dataArray[offset]; offset += 1 guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
// Check if version is 1 (only supported version) offset += 1
guard version == 1 else { return v
return nil }
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
offset += n
return d
} }
guard offset < dataArray.count else { return nil } // Version
let type = dataArray[offset]; offset += 1 guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
guard offset < dataArray.count else { return nil } // Timestamp 8 bytes BE
let ttl = dataArray[offset]; offset += 1 guard require(8) else { return nil }
var ts: UInt64 = 0
// Timestamp - need 8 bytes for _ in 0..<8 {
guard offset + 8 <= dataArray.count else { return nil } guard let b = read8() else { return nil }
let timestampData = Data(dataArray[offset..<offset+8]) ts = (ts << 8) | UInt64(b)
let timestamp = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
} }
offset += 8
// Flags // Flags
guard offset < dataArray.count else { return nil } guard let flags = read8() else { return nil }
let flags = dataArray[offset]; offset += 1
let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length - need 2 bytes // Payload length
guard offset + 2 <= dataArray.count else { return nil } guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
let payloadLengthData = Data(dataArray[offset..<offset+2])
let payloadLength = payloadLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
}
offset += 2
// Validate payloadLength is reasonable (prevent integer overflow) // SenderID
guard payloadLength <= 65535 else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
// SenderID - need 8 bytes // Recipient
guard offset + senderIDSize <= dataArray.count else { return nil } var recipientID: Data? = nil
let senderID = Data(dataArray[offset..<offset+senderIDSize])
offset += senderIDSize
// RecipientID if present
var recipientID: Data?
if hasRecipient { if hasRecipient {
guard offset + recipientIDSize <= dataArray.count else { return nil } recipientID = readData(recipientIDSize)
recipientID = Data(dataArray[offset..<offset+recipientIDSize]) if recipientID == nil { return nil }
offset += recipientIDSize
} }
// Payload handling with comprehensive bounds checking // Payload
let payload: Data let payload: Data
if isCompressed { if isCompressed {
// Compressed payload needs at least 2 bytes for original size // Need original size (2 bytes)
guard Int(payloadLength) >= 2 else { return nil } guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
// Check we have enough data for the original size prefix guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
guard offset + 2 <= dataArray.count else { return nil } let compSize = Int(payloadLen) - 2
let originalSizeData = Data(dataArray[offset..<offset+2]) guard compSize >= 0, let compressed = readData(compSize) else { return nil }
let originalSize = Int(originalSizeData.reduce(0) { result, byte in guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
(result << 8) | UInt16(byte) decompressed.count == originalSize else { return nil }
}) payload = decompressed
offset += 2
// Validate original size is reasonable
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
// Check we have enough data for the compressed payload
let compressedPayloadSize = Int(payloadLength) - 2
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
return nil
}
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
offset += compressedPayloadSize
// Decompress with error handling
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
return nil
}
// Verify decompressed size matches expected
guard decompressedPayload.count == originalSize else {
return nil
}
payload = decompressedPayload
} else { } else {
// Uncompressed payload guard let p = readData(Int(payloadLen)) else { return nil }
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else { payload = p
return nil
}
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
offset += Int(payloadLength)
} }
// Signature if present // Signature
var signature: Data? var signature: Data? = nil
if hasSignature { if hasSignature {
guard offset + signatureSize <= dataArray.count else { return nil } signature = readData(signatureSize)
signature = Data(dataArray[offset..<offset+signatureSize]) if signature == nil { return nil }
offset += signatureSize
} }
// Final validation: ensure we haven't gone past the end guard offset <= buf.count else { return nil }
guard offset <= dataArray.count else { return nil }
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: senderID, senderID: senderID,
recipientID: recipientID, recipientID: recipientID,
timestamp: timestamp, timestamp: ts,
payload: payload, payload: payload,
signature: signature, signature: signature,
ttl: ttl ttl: ttl
) )
} }
}
} }
// Binary encoding for BitchatMessage // Binary encoding for BitchatMessage
+24
View File
@@ -87,4 +87,28 @@ 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)
}
} }
+3
View File
@@ -15,6 +15,8 @@ struct AnnouncementPacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
// TLV for nickname // TLV for nickname
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil } guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
@@ -88,6 +90,7 @@ struct PrivateMessagePacket {
func encode() -> Data? { func encode() -> Data? {
var data = Data() var data = Data()
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
// TLV for messageID // TLV for messageID
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil } guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
+88 -36
View File
@@ -63,8 +63,9 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
// 5. Fragment Reassembly (necessary for messages > MTU) // 5. Fragment Reassembly (necessary for messages > MTU)
private var incomingFragments: [String: [Int: Data]] = [:] private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:] private var incomingFragments: [FragmentKey: [Int: Data]] = [:]
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
// Backoff for peripherals that recently timed out connecting // Backoff for peripherals that recently timed out connecting
private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout
@@ -88,6 +89,7 @@ final class BLEService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "anon" var myNickname: String = "anon"
private let noiseService = NoiseEncryptionService() private let noiseService = NoiseEncryptionService()
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias. // No Local Name by default for maximum privacy. No rotating alias.
@@ -223,7 +225,30 @@ final class BLEService: NSObject {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse) peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else { } else {
self.collectionsQueue.async(flags: .barrier) { self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data) var queue = self.pendingPeripheralWrites[uuid] ?? []
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
category: SecureLogger.session, level: .warning)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
category: SecureLogger.session, level: .warning)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
} }
} }
} }
@@ -308,6 +333,7 @@ final class BLEService: NSObject {
// Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16)) self.myPeerID = String(fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: myPeerID) ?? Data()
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
@@ -374,7 +400,7 @@ final class BLEService: NSObject {
maintenanceTimer = timer maintenanceTimer = timer
// Publish initial empty state // Publish initial empty state
publishFullPeerData() requestPeerDataPublish()
} }
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
@@ -463,7 +489,7 @@ final class BLEService: NSObject {
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
let leavePacket = BitchatPacket( let leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(), payload: Data(),
@@ -591,7 +617,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -643,7 +669,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID) let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -800,7 +826,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: recipientData, recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -854,7 +880,7 @@ final class BLEService: NSObject {
// Send handshake init // Send handshake init
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: handshakeData, payload: handshakeData,
@@ -904,7 +930,7 @@ final class BLEService: NSObject {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1247,8 +1273,11 @@ final class BLEService: NSObject {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
guard packet.payload.count >= 13 else { return } guard packet.payload.count >= 13 else { return }
let senderHex = packet.senderID.hexEncodedString() // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined() var senderU64: UInt64 = 0
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
var fragU64: UInt64 = 0
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
// Parse big-endian UInt16 safely without alignment assumptions // Parse big-endian UInt16 safely without alignment assumptions
let idxHi = UInt16(packet.payload[8]) let idxHi = UInt16(packet.payload[8])
let idxLo = UInt16(packet.payload[9]) let idxLo = UInt16(packet.payload[9])
@@ -1263,7 +1292,7 @@ final class BLEService: NSObject {
guard total > 0 && index >= 0 && index < total else { return } guard total > 0 && index >= 0 && index < total else { return }
// Store fragment // Store fragment
let key = "\(senderHex):\(fragmentID)" let key = FragmentKey(sender: senderU64, id: fragU64)
if incomingFragments[key] == nil { if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups // Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies { if incomingFragments.count >= maxInFlightAssemblies {
@@ -1436,6 +1465,20 @@ final class BLEService: NSObject {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }
var verifiedAnnounce = false
if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verifiedAnnounce = false
}
// Track if this is a new or reconnected peer // Track if this is a new or reconnected peer
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
@@ -1460,21 +1503,8 @@ final class BLEService: NSObject {
isNewPeer = (existingPeer == nil) isNewPeer = (existingPeer == nil)
isReconnectedPeer = wasDisconnected isReconnectedPeer = wasDisconnected
// Verify packet signature using the announced signing public key // Use precomputed verification result
var verified = false let verified = verifiedAnnounce
if packet.signature != nil {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verified {
SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
}
}
// If existing peer has a different noise public key, do not consider this verified
if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility) // Require verified announce; ignore otherwise (no backward compatibility)
if !verified { if !verified {
@@ -1565,7 +1595,7 @@ final class BLEService: NSObject {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(peerID)
} }
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
@@ -1666,7 +1696,7 @@ final class BLEService: NSObject {
// Send response // Send response
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue, type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response, payload: response,
@@ -1822,7 +1852,7 @@ final class BLEService: NSObject {
// Create packet with signature using the noise private key // Create packet with signature using the noise private key
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.announce.rawValue, type: MessageType.announce.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -1856,7 +1886,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1893,7 +1923,7 @@ final class BLEService: NSObject {
let encrypted = try noiseService.encrypt(payload, for: peerID) let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(), senderID: myPeerIDData,
recipientID: Data(hexString: peerID), recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
@@ -1960,6 +1990,28 @@ final class BLEService: NSObject {
} }
} }
// Debounced publish to coalesce rapid changes
private var lastPeerPublishAt: Date = .distantPast
private var peerPublishPending: Bool = false
private let peerPublishMinInterval: TimeInterval = 0.1
private func requestPeerDataPublish() {
let now = Date()
let elapsed = now.timeIntervalSince(lastPeerPublishAt)
if elapsed >= peerPublishMinInterval {
lastPeerPublishAt = now
publishFullPeerData()
} else if !peerPublishPending {
peerPublishPending = true
let delay = peerPublishMinInterval - elapsed
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
self.lastPeerPublishAt = Date()
self.peerPublishPending = false
self.publishFullPeerData()
}
}
}
// MARK: - Consolidated Maintenance // MARK: - Consolidated Maintenance
private func performMaintenance() { private func performMaintenance() {
@@ -2072,7 +2124,7 @@ final class BLEService: NSObject {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(peerID)
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons // Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2454,7 +2506,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
} }
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2855,7 +2907,7 @@ extension BLEService: CBPeripheralManagerDelegate {
self.notifyPeerDisconnectedDebounced(peerID) self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly // Publish snapshots so UnifiedPeerService can refresh icons promptly
self.publishFullPeerData() self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -0,0 +1,227 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private init() {
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
}
+54 -15
View File
@@ -50,10 +50,8 @@ 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)
} }
// Initialize teleported flag from persisted state if a location channel is selected // Do not eagerly mark teleported on startup; wait for location to compute regional set.
if case .location(let ch) = selectedChannel { // This avoids showing teleported for in-region channels during cold start.
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
@@ -61,6 +59,15 @@ 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
@@ -92,23 +99,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin continuous, distance-filtered updates while the channel sheet is visible.
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Stop any previous polling timer
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in refreshTimer = nil
self?.requestOneShotLocation() // Tighten accuracy and distance filter for live view
} cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// Kick off immediately cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
// Start continuous updates
cl.startUpdatingLocation()
// Request an immediate fix to populate UI without waiting for movement
requestOneShotLocation() requestOneShotLocation()
} }
/// Stop periodic refreshes when selector UI is dismissed. /// Stop continuous refreshes when selector UI is dismissed.
func endLiveRefresh() { func endLiveRefresh() {
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = nil refreshTimer = nil
cl.stopUpdatingLocation() cl.stopUpdatingLocation()
// Restore more relaxed defaults for background/idle state
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
} }
func select(_ channel: ChannelID) { func select(_ channel: ChannelID) {
@@ -122,10 +136,24 @@ 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) {
@@ -195,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
Task { @MainActor in Task { @MainActor in
self.availableChannels = result self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel // Recompute teleported status based on whether the selected geohash is in our regional set
switch self.selectedChannel { switch self.selectedChannel {
case .mesh: case .mesh:
self.teleported = false self.teleported = false
case .location(let ch): case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash) // Membership check using freshly computed regional channels; avoids precision/rename drift
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) let inRegional = result.contains { $0.geohash == ch.geohash }
self.teleported = persisted || (currentGH != ch.geohash) if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
} }
} }
} }
+20 -5
View File
@@ -89,11 +89,20 @@ final class MessageRouter {
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
guard let noiseKey = Data(hexString: peerID) else { return false } // Two forms are supported:
// - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey)
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
} else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil {
return true
}
}
return false return false
} }
@@ -101,6 +110,7 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// 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.isPeerReachable(peerID) {
@@ -112,14 +122,19 @@ final class MessageRouter {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
continue // Keep unsent items queued
remaining.append((content, nickname, messageID))
} }
} }
// Remove all flushed items (remaining ones, if any, will be re-queued on next call) // Persist only items we could not send
outbox[peerID]?.removeAll() if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
outbox[peerID] = remaining
}
} }
func flushAllOutbox() { func flushAllOutbox() {
for key in outbox.keys { flushOutbox(for: key) } for key in Array(outbox.keys) { flushOutbox(for: key) }
} }
} }
@@ -90,6 +90,15 @@ class NotificationService {
} }
} }
// Geohash public chat notification with deep link to a specific geohash
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
let title = "\(titlePrefix)\(geohash)"
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
let deeplink = "bitchat://geohash/\(geohash)"
let userInfo: [String: Any] = ["deeplink": deeplink]
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
}
func sendNetworkAvailableNotification(peerCount: Int) { func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
+10
View File
@@ -100,8 +100,14 @@ enum TransportConfig {
// Location // Location
static let locationDistanceFilterMeters: Double = 1000 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 static let locationLiveRefreshInterval: TimeInterval = 5.0
// Notifications (geohash)
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
static let uiGeoNotifySnippetMaxLen: Int = 80
// Nostr geohash // Nostr geohash
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600 static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
static let nostrGeohashInitialLimit: Int = 200 static let nostrGeohashInitialLimit: Int = 200
@@ -169,6 +175,10 @@ enum TransportConfig {
// UI color tuning // UI color tuning
static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueAvoidanceDelta: Double = 0.05
static let uiColorHueOffset: Double = 0.12 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) // UI windowing (infinite scroll)
static let uiWindowInitialCountPublic: Int = 300 static let uiWindowInitialCountPublic: Int = 300
+7 -1
View File
@@ -27,6 +27,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [String: BitchatPeer] = [:] private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport private let meshService: Transport
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -330,8 +331,13 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer // Send favorite notification to the peer via router (mesh or Nostr)
if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite) meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
}
// Force update of peers to reflect the change // Force update of peers to reflect the change
updatePeers() updatePeers()
+21 -7
View File
@@ -9,6 +9,7 @@ final class MessageDeduplicator {
} }
private var entries: [Entry] = [] private var entries: [Entry] = []
private var head: Int = 0
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 = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
@@ -28,10 +29,18 @@ final class MessageDeduplicator {
entries.append(Entry(messageID: messageID, timestamp: Date())) entries.append(Entry(messageID: messageID, timestamp: Date()))
lookup.insert(messageID) lookup.insert(messageID)
if entries.count > maxCount { // Soft-cap and advance head by a chunk to avoid O(n) shifting
let toRemove = entries.prefix(100) if (entries.count - head) > maxCount {
toRemove.forEach { lookup.remove($0.messageID) } let removeCount = min(100, entries.count - head)
entries.removeFirst(100) for i in head..<(head + removeCount) {
lookup.remove(entries[i].messageID)
}
head += removeCount
// Periodically compact to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
} }
return false return false
@@ -61,6 +70,7 @@ final class MessageDeduplicator {
defer { lock.unlock() } defer { lock.unlock() }
entries.removeAll() entries.removeAll()
head = 0
lookup.removeAll() lookup.removeAll()
} }
@@ -78,9 +88,13 @@ final class MessageDeduplicator {
private func cleanupOldEntries() { private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge) let cutoff = Date().addingTimeInterval(-maxAge)
while let first = entries.first, first.timestamp < cutoff { while head < entries.count, entries[head].timestamp < cutoff {
lookup.remove(first.messageID) lookup.remove(entries[head].messageID)
entries.removeFirst() head += 1
}
if head > 0 && head > entries.count / 2 {
entries.removeFirst(head)
head = 0
} }
} }
} }
+4 -4
View File
@@ -140,11 +140,11 @@ class SecureLogger {
} }
/// Log general messages with automatic sensitive data filtering /// Log general messages with automatic sensitive data filtering
static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug, static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
guard shouldLog(level) else { return } guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message)") let sanitized = sanitize("\(location) \(message())")
#if DEBUG #if DEBUG
os_log("%{public}@", log: category, type: level.osLogType, sanitized) os_log("%{public}@", log: category, type: level.osLogType, sanitized)
@@ -157,10 +157,10 @@ class SecureLogger {
} }
/// Log errors with context /// Log errors with context
static func logError(_ error: Error, context: String, category: OSLog = noise, static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise,
file: String = #file, line: Int = #line, function: String = #function) { file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function) let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context) let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription) let errorDesc = sanitize(error.localizedDescription)
#if DEBUG #if DEBUG
+578 -83
View File
@@ -383,6 +383,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
// Presentation state for privacy gating
@Published var isLocationChannelsSheetPresented: Bool = false
@Published var isAppInfoPresented: Bool = false
@Published var showScreenshotPrivacyWarning: Bool = false
// Messages are naturally ephemeral - no persistent storage // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = [] private var meshTimeline: [BitchatMessage] = []
@@ -402,6 +407,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex @Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open) // Sampling subscriptions for multiple geohashes (when channel sheet is open)
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking // MARK: - Message Delivery Tracking
@@ -486,6 +492,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter // Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter self.privateChatManager.messageRouter = self.messageRouter
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
self.unifiedPeerService.messageRouter = self.messageRouter
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
// Wire up dependencies // Wire up dependencies
@@ -585,6 +593,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if connected { if connected {
Task { @MainActor in Task { @MainActor in
self.resubscribeCurrentGeohash() self.resubscribeCurrentGeohash()
// Re-init sampling for regional + bookmarked geohashes after reconnect
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
self.beginGeohashSampling(for: union)
} }
} }
} }
@@ -610,6 +623,70 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel) self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
} }
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
LocationChannelManager.shared.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self = self else { return }
let regional = channels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
self.beginGeohashSampling(for: union)
}
}
.store(in: &cancellables)
// Also observe bookmark changes to update sampling
GeohashBookmarksStore.shared.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self = self else { return }
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
self.beginGeohashSampling(for: union)
}
}
.store(in: &cancellables)
// Kick off initial sampling if we have regional channels or bookmarks
do {
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if !union.isEmpty {
Task { @MainActor in self.beginGeohashSampling(for: union) }
}
}
// Refresh channels once when authorized to seed sampling
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { state in
if state == .authorized { LocationChannelManager.shared.refreshChannels() }
}
.store(in: &cancellables)
// Track teleport flag changes to keep our own teleported marker in sync with regional status
LocationChannelManager.shared.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] isTeleported in
guard let self = self else { return }
Task { @MainActor in
guard case .location(let ch) = self.activeChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let key = id.publicKeyHex.lowercased()
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if isTeleported && hasRegional && !inRegional {
self.teleportedGeo = self.teleportedGeo.union([key])
} else {
self.teleportedGeo.remove(key)
}
}
}
.store(in: &cancellables)
// Request notification permission // Request notification permission
NotificationService.shared.requestAuthorization() NotificationService.shared.requestAuthorization()
@@ -747,10 +824,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}) })
if hasTeleportTag { if hasTeleportTag {
let key = event.pubkey.lowercased() let key = event.pubkey.lowercased()
// Do not mark our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) } Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
} }
}
let senderName = self.displayNameForNostrPubkey(event.pubkey) let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = self.parseMentions(from: content) let mentions = self.parseMentions(from: content)
let msg = BitchatMessage( let msg = BitchatMessage(
@@ -1192,7 +1278,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Routes to private chat if one is selected, otherwise broadcasts /// Routes to private chat if one is selected, otherwise broadcasts
@MainActor @MainActor
func sendMessage(_ content: String) { func sendMessage(_ content: String) {
guard !content.isEmpty else { return } // Ignore messages that are empty or whitespace-only to prevent blank lines
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
// Check for commands // Check for commands
if content.hasPrefix("/") { if content.hasPrefix("/") {
@@ -1212,7 +1300,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else { } else {
} }
} else { } else {
// Parse mentions from the content // Parse mentions from the content (use original content for user intent)
let mentions = parseMentions(from: content) let mentions = parseMentions(from: content)
// Add message to local display // Add message to local display
@@ -1227,7 +1315,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let message = BitchatMessage( let message = BitchatMessage(
sender: displaySender, sender: displaySender,
content: content, content: trimmed,
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
@@ -1272,7 +1360,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
do { do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent( let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content, content: trimmed,
geohash: ch.geohash, geohash: ch.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: self.nickname, nickname: self.nickname,
@@ -1292,7 +1380,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
if LocationChannelManager.shared.teleported { // Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased() let key = identity.publicKeyHex.lowercased()
self.teleportedGeo = self.teleportedGeo.union([key]) self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
@@ -1323,16 +1414,38 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline messages = meshTimeline
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 {
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug)
}
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll() teleportedGeo.removeAll()
case .location(let ch): case .location(let ch):
// Sanitize existing timeline (filter any prior empty-content entries) // Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
let before = arr.count
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
if arr.count != before { geoTimelines[ch.geohash] = arr } // Deduplicate by ID while preserving order (from oldest to newest)
if arr.count > 1 {
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
arr = dedup
}
// Persist the cleaned/sorted timeline for this geohash
geoTimelines[ch.geohash] = arr
messages = arr messages = arr
// Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyGeo > 0 {
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
}
} }
// Unsubscribe previous // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
@@ -1350,14 +1463,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard case .location(let ch) = channel else { return } guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash currentGeohash = ch.geohash
// Ensure self appears immediately in the people list; mark teleported state if applicable // Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
if LocationChannelManager.shared.teleported { let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased() let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
teleportedGeo = teleportedGeo.union([key]) teleportedGeo = teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
category: SecureLogger.session, level: .info) category: SecureLogger.session, level: .info)
} else {
teleportedGeo.remove(key)
} }
} }
let subID = "geo-\(ch.geohash)" let subID = "geo-\(ch.geohash)"
@@ -1386,12 +1503,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}) })
if hasTeleportTag { if hasTeleportTag {
let key = event.pubkey.lowercased() let key = event.pubkey.lowercased()
// Avoid marking our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor in Task { @MainActor in
self.teleportedGeo = self.teleportedGeo.union([key]) self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
category: SecureLogger.session, level: .info) category: SecureLogger.session, level: .info)
} }
} }
}
// Skip only very recent self-echo from relay; include older self events for hydration // Skip only very recent self-echo from relay; include older self events for hydration
if let gh = self.currentGeohash, if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
@@ -1741,8 +1867,75 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let existingCount: Int = {
let map = self.geoParticipants[gh] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}()
// Update participants for this specific geohash // Update participants for this specific geohash
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
// Respect geohash blocks
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently
guard existingCount == 0 else { return }
// Avoid notifications for old sampled events when launching or (re)subscribing
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
// Foreground policy: allow if it's a different geohash than the one currently open
#if os(iOS)
if UIApplication.shared.applicationState == .active {
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
}
#elseif os(macOS)
if NSApplication.shared.isActive {
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
}
#endif
// Cooldown per geohash
let now = Date()
let last = self.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
// Compose a short preview
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor in
self.lastGeoNotificationAt[gh] = now
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
var arr = self.geoTimelines[gh] ?? []
let senderSuffix = String(event.pubkey.suffix(4))
let nick = self.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
if !arr.contains(where: { $0.id == msg.id }) {
arr.append(msg)
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
self.geoTimelines[gh] = arr
}
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
} }
} }
} }
@@ -2420,6 +2613,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
@objc private func userDidTakeScreenshot() { @objc private func userDidTakeScreenshot() {
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
if isLocationChannelsSheetPresented {
// Show a warning about sharing location screenshots publicly
showScreenshotPrivacyWarning = true
return
}
if isAppInfoPresented {
// Silently ignore screenshots of app info
return
}
// Send screenshot notification based on current context // Send screenshot notification based on current context
let screenshotMessage = "* \(nickname) took a screenshot *" let screenshotMessage = "* \(nickname) took a screenshot *"
@@ -2439,7 +2643,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// Show local notification immediately as system message // Show local notification immediately as system message (only in chat)
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -2490,7 +2694,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Show local notification immediately as system message // Show local notification immediately as system message (only in chat)
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -2875,8 +3079,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsContent = contentText as NSString
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsLen = nsContent.length
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
// Combine and sort all matches // Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = [] var allMatches: [(range: NSRange, type: String)] = []
@@ -2893,6 +3099,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (matchRange, matchType) in allMatches { for (matchRange, matchType) in allMatches {
// Add text before the match // Add text before the match
if let range = Range(matchRange, in: contentText) { if let range = Range(matchRange, in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var normalStyle = AttributeContainer() var normalStyle = AttributeContainer()
@@ -2900,6 +3107,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
} }
}
// Add the match with appropriate styling // Add the match with appropriate styling
let matchText = String(contentText[range]) let matchText = String(contentText[range])
@@ -2916,7 +3124,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle)) processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
lastEndIndex = range.upperBound if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
} }
} }
@@ -2993,9 +3201,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work, // For extremely long content, render as plain text to avoid heavy regex/layout work,
// unless the content includes Cashu tokens we want to chip-render below // unless the content includes Cashu tokens we want to chip-render below
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = { let containsCashuEarly: Bool = {
let rx = Regexes.quickCashuPresence let rx = Regexes.quickCashuPresence
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}() }()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
@@ -3013,8 +3224,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lnurlRegex = Regexes.lnurl let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector let detector = Regexes.linkDetector
let nsLen = content.count
let hasMentionsHint = content.contains("@") let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#") let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
@@ -3035,8 +3244,29 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } } for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } }
return false return false
} }
// Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd)
func attachedToMention(_ r: NSRange) -> Bool {
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
var i = content.index(before: nsRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
}
return false
}
// Helper: ensure '#' starts a new token (start-of-line or whitespace before '#')
func isStandaloneHashtag(_ r: NSRange) -> Bool {
guard let nsRange = Range(r, in: content) else { return false }
if nsRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: nsRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}
var allMatches: [(range: NSRange, type: String)] = [] var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) { for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "hashtag")) allMatches.append((match.range(at: 0), "hashtag"))
} }
for match in mentionMatches { for match in mentionMatches {
@@ -3073,6 +3303,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (range, type) in allMatches { for (range, type) in allMatches {
// Add text before match // Add text before match
if let nsRange = Range(range, in: content) { if let nsRange = Range(range, in: content) {
if lastEnd < nsRange.lowerBound {
let beforeText = String(content[lastEnd..<nsRange.lowerBound]) let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var beforeStyle = AttributeContainer() var beforeStyle = AttributeContainer()
@@ -3085,6 +3316,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle)) result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
}
// Add styled match // Add styled match
let matchText = String(content[nsRange]) let matchText = String(content[nsRange])
@@ -3144,12 +3376,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
return false return false
}() }()
// Also require the '#' to start a new token (whitespace or start-of-line before '#')
let standalone: Bool = {
if nsRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: nsRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}()
var tagStyle = AttributeContainer() var tagStyle = AttributeContainer()
tagStyle.font = isSelf tagStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced) ? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced) : .system(size: 14, design: .monospaced)
tagStyle.foregroundColor = baseColor tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") { if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url tagStyle.link = url
tagStyle.underlineStyle = .single tagStyle.underlineStyle = .single
} }
@@ -3185,9 +3423,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(matchText).mergingAttributes(matchStyle)) result.append(AttributedString(matchText).mergingAttributes(matchStyle))
} }
} }
// Advance lastEnd safely in case of overlaps
if lastEnd < nsRange.upperBound {
lastEnd = nsRange.upperBound lastEnd = nsRange.upperBound
} }
} }
}
// Add remaining text // Add remaining text
if lastEnd < content.endIndex { if lastEnd < content.endIndex {
@@ -3283,13 +3524,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Regular expression to find @mentions // Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)" let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsContent = contentText as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var lastEndIndex = contentText.startIndex var lastEndIndex = contentText.startIndex
for match in matches { for match in matches {
// Add text before the mention // Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) { if let range = Range(match.range(at: 0), in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty { if !beforeText.isEmpty {
var normalStyle = AttributeContainer() var normalStyle = AttributeContainer()
@@ -3297,6 +3541,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
} }
}
// Add the mention with highlight // Add the mention with highlight
let mentionText = String(contentText[range]) let mentionText = String(contentText[range])
@@ -3305,7 +3550,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
mentionStyle.foregroundColor = Color.orange mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle)) processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
lastEndIndex = range.upperBound if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
} }
} }
@@ -3488,14 +3733,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color {
let cacheKey = seed + (isDark ? "|dark" : "|light") let cacheKey = seed + (isDark ? "|dark" : "|light")
if let cached = peerColorCache[cacheKey] { return cached } if let cached = peerColorCache[cacheKey] { return cached }
var hue = Double(djb2(seed) % 360) / 360.0 let h = djb2(seed)
// Avoid orange (~30°) reserved for self var hue = Double(h % 1000) / 1000.0
let orange = 30.0 / 360.0 let orange = 30.0 / 360.0
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta { if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0) hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
} }
let saturation: Double = isDark ? 0.80 : 0.70 let sRand = Double((h >> 17) & 0x3FF) / 1023.0
let brightness: Double = isDark ? 0.75 : 0.45 let bRand = Double((h >> 27) & 0x3FF) / 1023.0
let sBase: Double = isDark ? 0.80 : 0.70
let sRange: Double = 0.20
let bBase: Double = isDark ? 0.75 : 0.45
let bRange: Double = isDark ? 0.16 : 0.14
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
let c = Color(hue: hue, saturation: saturation, brightness: brightness) let c = Color(hue: hue, saturation: saturation, brightness: brightness)
peerColorCache[cacheKey] = c peerColorCache[cacheKey] = c
return c return c
@@ -3503,41 +3754,35 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
var seed: String
if let spid = message.senderPeerID { if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
// Normalize to the bare short id, then prefer full mapping when available
let bare: String = { let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) } if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) } if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid return spid
}() }()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased() let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
seed = "nostr:" + full return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { } else if spid.count == 16 {
seed = "noise:" + full // Mesh short ID
return getPeerPaletteColor(for: spid, isDark: isDark)
} else { } else {
seed = spid.lowercased() return getPeerPaletteColor(for: spid.lowercased(), isDark: isDark)
} }
} else {
seed = message.sender.lowercased()
} }
return colorForPeerSeed(seed, isDark: isDark) // Fallback when we only have a display name
return colorForPeerSeed(message.sender.lowercased(), isDark: isDark)
} }
// Public helpers for views to color peers consistently in lists // Public helpers for views to color peers consistently in lists
@MainActor @MainActor
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
return colorForPeerSeed("nostr:" + pubkeyHexLowercased.lowercased(), isDark: isDark) return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
} }
@MainActor @MainActor
func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color { func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color {
// Mirror message coloring: prefer stable full noise key mapping when available, else short ID return getPeerPaletteColor(for: peerID, isDark: isDark)
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
return colorForPeerSeed("noise:" + full, isDark: isDark)
}
return colorForPeerSeed(peerID.lowercased(), isDark: isDark)
} }
private func trimMeshTimelineIfNeeded() { private func trimMeshTimelineIfNeeded() {
@@ -3546,6 +3791,276 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// MARK: - Peer List Minimal-Distance Palette
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
@MainActor
private func meshSeed(for peerID: String) -> String {
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
return "noise:" + full
}
return peerID.lowercased()
}
@MainActor
private func getPeerPaletteColor(for peerID: String, isDark: Bool) -> Color {
// Ensure palette up to date for current peer set and seeds
rebuildPeerPaletteIfNeeded()
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
let orange = Color.orange
if peerID == meshService.myPeerID { return orange }
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color if not in palette (e.g., transient)
let seed = meshSeed(for: peerID)
return colorForPeerSeed(seed, isDark: isDark)
}
@MainActor
private func rebuildPeerPaletteIfNeeded() {
// Build current peer->seed map (excluding self)
let myID = meshService.myPeerID
var currentSeeds: [String: String] = [:]
for p in allPeers where p.id != myID {
currentSeeds[p.id] = meshSeed(for: p.id)
}
// If seeds unchanged and palette exists for both themes, skip
if currentSeeds == peerPaletteSeeds,
peerPaletteLight.keys.count == currentSeeds.count,
peerPaletteDark.keys.count == currentSeeds.count {
return
}
peerPaletteSeeds = currentSeeds
// Generate evenly spaced hue slots avoiding self-orange range
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
let avoidCenter = 30.0 / 360.0
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
// Helper to compute circular distance
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
// Assign slots to peers to maximize minimal distance, deterministically
let peers = currentSeeds.keys.sorted() // stable order
// Preferred slot index by seed (wrapping to available slots)
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = djb2(currentSeeds[id] ?? id)
// Map to available slot range deterministically
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] {
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
// Keep previous assignments if still valid to minimize churn
let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight
for (id, entry) in prev {
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
// First ring assignment using free slots
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
// If a preferred slot free, take it
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
// Choose free slot maximizing minimal distance to used hues
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
// Bias toward preferred index for stability
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
// Overflow peers: assign additional rings by reusing slots with stable preference
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
// Spread over slots by rotating from preferred with a golden-step
let goldenStep = 7 // small prime step for dispersion
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
return mapping
}
let mapping = assign(for: currentSeeds)
peerPaletteLight = mapping
peerPaletteDark = mapping
}
// MARK: - Nostr People Minimal-Distance Palette (same algo)
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
@MainActor
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
rebuildNostrPaletteIfNeeded()
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
if let me = myHex, pubkeyHexLowercased == me { return .orange }
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color if not in palette (e.g., transient)
return colorForPeerSeed("nostr:" + pubkeyHexLowercased, isDark: isDark)
}
@MainActor
private func rebuildNostrPaletteIfNeeded() {
// Build seeds map from currently visible geohash people (excluding self)
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let people = visibleGeohashPeople()
var currentSeeds: [String: String] = [:]
for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id }
if currentSeeds == nostrPaletteSeeds,
nostrPaletteLight.keys.count == currentSeeds.count,
nostrPaletteDark.keys.count == currentSeeds.count {
return
}
nostrPaletteSeeds = currentSeeds
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
let avoidCenter = 30.0 / 360.0
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
let peers = currentSeeds.keys.sorted()
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = djb2(currentSeeds[id] ?? id)
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight
for (id, entry) in prev {
if peers.contains(id), entry.slot < slots.count {
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
let goldenStep = 7
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
nostrPaletteLight = mapping
nostrPaletteDark = mapping
}
// Clear the current public channel's timeline (visible + persistent buffer) // Clear the current public channel's timeline (visible + persistent buffer)
@MainActor @MainActor
func clearCurrentPublicTimeline() { func clearCurrentPublicTimeline() {
@@ -3933,11 +4448,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in Task { @MainActor in
let publicMentions = parseMentions(from: content) let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage( let msg = BitchatMessage(
id: UUID().uuidString, id: UUID().uuidString,
sender: nickname, sender: nickname,
content: content, content: normalized,
timestamp: timestamp, timestamp: timestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
@@ -4106,19 +4622,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Cancel any pending reset if peers are back // Cancel any pending reset if peers are back
self.networkResetTimer?.invalidate() self.networkResetTimer?.invalidate()
self.networkResetTimer = nil self.networkResetTimer = nil
// Only count mesh peers (actually connected via Bluetooth) // Count mesh peers that are connected OR recently reachable via mesh relays
let meshPeers = peers.filter { peerID in let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
} }
// Check if we have new mesh peers we haven't seen recently // Rising-edge only: previously zero peers, now > 0 peers
let currentPeerSet = Set(meshPeers) let currentPeerSet = Set(meshPeers)
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers) let hadNone = self.recentlySeenPeers.isEmpty
// Send notification if: if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
// 1. We have mesh peers (not just Nostr-only)
// 2. There are new peers we haven't seen (rising-edge)
// 3. We haven't already notified since the last sustained-empty period
if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable {
self.hasNotifiedNetworkAvailable = true self.hasNotifiedNetworkAvailable = true
self.lastNetworkNotificationTime = Date() self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet self.recentlySeenPeers = currentPeerSet
@@ -4127,16 +4639,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info) category: SecureLogger.session, level: .info)
} }
} else { } else {
// No peers - schedule a graceful reset to avoid refiring on brief drops // No peers immediately reset to allow next rising-edge to notify
if self.networkResetTimer == nil {
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
guard let self = self else { return }
self.hasNotifiedNetworkAvailable = false self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll() self.recentlySeenPeers.removeAll()
if self.networkResetTimer != nil {
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil self.networkResetTimer = nil
SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug)
}
} }
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug)
} }
// Register ephemeral sessions for all connected peers // Register ephemeral sessions for all connected peers
@@ -4238,7 +4748,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames // Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let nsContent = content as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var mentions: [String] = [] var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames() let peerNicknames = meshService.getPeerNicknames()
@@ -5305,11 +5817,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isGeo && finalMessage.sender != "system" { if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
// Dedup by message ID before appending to per-geohash timeline
if !arr.contains(where: { $0.id == finalMessage.id }) {
arr.append(finalMessage) arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr geoTimelines[gh] = arr
} }
} }
}
// Only add message to current timeline if it matches active channel or is system // Only add message to current timeline if it matches active channel or is system
let isSystem = finalMessage.sender == "system" let isSystem = finalMessage.sender == "system"
@@ -5322,35 +5837,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard channelMatches else { return } guard channelMatches else { return }
// Background nudge: notify on new activity after inactivity threshold in current channel // Removed background nudge notification for generic "new chats!"
#if os(iOS)
if UIApplication.shared.applicationState != .active {
let channelKey: String = {
switch activeChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
let now = Date()
if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold {
// Optional: simple cooldown to avoid duplicate bursts
let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast
if now.timeIntervalSince(lastNotified) >= 60 {
let title = activeChannelDisplayName()
let body = "new chats!"
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)")
lastPublicActivityNotifyAt[channelKey] = now
}
}
lastPublicActivityAt[channelKey] = now
}
#endif
// Append via batching buffer (skip empty content) // Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !messages.contains(where: { $0.id == finalMessage.id }) {
enqueuePublic(finalMessage) enqueuePublic(finalMessage)
} }
} }
}
// MARK: - Public message batching helpers // MARK: - Public message batching helpers
@MainActor @MainActor
+28 -1
View File
@@ -24,6 +24,7 @@ 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
@@ -164,6 +165,8 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -278,8 +281,10 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(items, id: \.uiID) { item in ForEach(filteredItems, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -459,7 +464,13 @@ 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) {
@@ -1112,6 +1123,15 @@ 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 = {
@@ -1166,6 +1186,13 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
} }
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
+96 -31
View File
@@ -8,6 +8,7 @@ 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 = ""
@@ -83,29 +84,22 @@ struct LocationChannelsSheet: View {
} }
// Begin periodic refresh while sheet is open // Begin periodic refresh while sheet is open
manager.beginLiveRefresh() manager.beginLiveRefresh()
// Begin multi-channel sampling for counts // Geohash sampling is now managed by ChatViewModel globally
let ghs = manager.availableChannels.map { $0.geohash }
viewModel.beginGeohashSampling(for: ghs)
} }
.onDisappear { .onDisappear {
manager.endLiveRefresh() manager.endLiveRefresh()
viewModel.endGeohashSampling()
} }
.onChange(of: manager.permissionState) { newValue in .onChange(of: manager.permissionState) { newValue in
if newValue == LocationChannelManager.PermissionState.authorized { if newValue == LocationChannelManager.PermissionState.authorized {
manager.refreshChannels() manager.refreshChannels()
} }
} }
.onChange(of: manager.availableChannels) { newValue in .onChange(of: manager.availableChannels) { _ in }
// Keep sampling list in sync with available channels as they refresh live
let ghs = newValue.map { $0.geohash }
viewModel.beginGeohashSampling(for: ghs)
}
} }
private var channelList: some View { private var channelList: some View {
List { List {
// Mesh option first // Mesh option first (no bookmark)
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
@@ -119,7 +113,21 @@ 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(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { channelRow(
title: geohashTitleWithCount(for: channel),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// Selecting a suggested nearby channel is not a teleport. Persist this. // 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))
@@ -195,6 +203,48 @@ 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: {
@@ -227,9 +277,19 @@ struct LocationChannelsSheet: View {
return false return false
} }
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 { @ViewBuilder
Button(action: action) { private func channelRow(
HStack { title: String,
subtitlePrefix: String,
subtitleName: String? = nil,
subtitleNameBold: Bool = false,
isSelected: Bool,
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) { 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)
@@ -244,20 +304,17 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
HStack(spacing: 0) { let subtitleFull: String = {
Text(subtitlePrefix) if let name = subtitleName, !name.isEmpty {
.font(.system(size: 12, design: .monospaced)) return subtitlePrefix + "" + name
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
} }
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {
@@ -265,11 +322,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]"
@@ -289,20 +346,28 @@ struct LocationChannelsSheet: View {
} }
private func meshCount() -> Int { private func meshCount() -> Int {
// Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID let myID = viewModel.meshService.myPeerID
return viewModel.allPeers.reduce(0) { acc, peer in return viewModel.allPeers.reduce(0) { acc, peer in
if peer.id != myID && peer.isConnected { return acc + 1 } if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
return acc return acc
} }
} }
private func geohashTitleWithCount(for channel: GeohashChannel) -> String { private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels // Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash) let 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 }
@@ -0,0 +1,51 @@
import XCTest
@testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase {
let storeKey = "locationChannel.bookmarks"
override func setUp() {
super.setUp()
// Clear persisted state before each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
}
override func tearDown() {
// Clean after each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
super.tearDown()
}
func testToggleAndNormalize() {
let store = GeohashBookmarksStore.shared
// Start clean
XCTAssertTrue(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix
store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy")
// Toggling again removes
store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty)
}
func testPersistenceWritten() throws {
let store = GeohashBookmarksStore.shared
store.toggle("ezs42")
store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here)
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy"))
}
}