mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:05:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4c596a17 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## bitchat
|
## bitchat
|
||||||
|
|
||||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||||
|
|
||||||
[bitchat.free](http://bitchat.free)
|
[bitchat.free](http://bitchat.free)
|
||||||
|
|
||||||
@@ -11,99 +11,50 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
|
||||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
|
||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||||
|
|
||||||
|
|
||||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||||
|
|
||||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
### Binary Protocol
|
||||||
|
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||||
|
- Compact packet format with 1-byte type field
|
||||||
|
- TTL-based message routing (max 7 hops)
|
||||||
|
- Automatic fragmentation for large messages
|
||||||
|
- Message deduplication via unique IDs
|
||||||
|
|
||||||
### Bluetooth Mesh Network (Offline)
|
### Mesh Networking
|
||||||
|
- Each device acts as both client and peripheral
|
||||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
- Automatic peer discovery and connection management
|
||||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
- Adaptive duty cycling for battery optimization
|
||||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
|
||||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
|
||||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
|
||||||
- **Automatic Discovery**: Peer discovery and connection management
|
|
||||||
- **Adaptive Power**: Battery-optimized duty cycling
|
|
||||||
|
|
||||||
### Nostr Protocol (Internet)
|
|
||||||
|
|
||||||
- **Global Reach**: Connect with users worldwide via internet relays
|
|
||||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
|
||||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
|
||||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
|
||||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
|
||||||
|
|
||||||
### Channel Types
|
|
||||||
|
|
||||||
#### `mesh #bluetooth`
|
|
||||||
|
|
||||||
- **Transport**: Bluetooth Low Energy mesh network
|
|
||||||
- **Scope**: Local devices within multi-hop range
|
|
||||||
- **Internet**: Not required
|
|
||||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
|
||||||
|
|
||||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
|
||||||
|
|
||||||
- **Transport**: Nostr protocol over internet
|
|
||||||
- **Scope**: Geographic areas defined by geohash precision
|
|
||||||
- `block` (7 chars): City block level
|
|
||||||
- `neighborhood` (6 chars): District/neighborhood
|
|
||||||
- `city` (5 chars): City level
|
|
||||||
- `province` (4 chars): State/province
|
|
||||||
- `region` (2 chars): Country/large region
|
|
||||||
- **Internet**: Required (connects to Nostr relays)
|
|
||||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
|
||||||
|
|
||||||
### Direct Message Routing
|
|
||||||
|
|
||||||
Private messages use **intelligent transport selection**:
|
|
||||||
|
|
||||||
1. **Bluetooth First** (preferred when available)
|
|
||||||
|
|
||||||
- Direct connection with established Noise session
|
|
||||||
- Fastest and most private option
|
|
||||||
|
|
||||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
|
||||||
|
|
||||||
- Uses recipient's Nostr public key
|
|
||||||
- NIP-17 gift-wrapping for privacy
|
|
||||||
- Routes through global relay network
|
|
||||||
|
|
||||||
3. **Smart Queuing** (when neither available)
|
|
||||||
- Messages queued until transport becomes available
|
|
||||||
- Automatic delivery when connection established
|
|
||||||
|
|
||||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||||
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using XcodeGen (Recommended)
|
### Option 1: Using XcodeGen (Recommended)
|
||||||
|
|
||||||
1. Install XcodeGen if you haven't already:
|
1. Install XcodeGen if you haven't already:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install xcodegen
|
brew install xcodegen
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Generate the Xcode project:
|
2. Generate the Xcode project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
### Option 2: Using Swift Package Manager
|
### Option 2: Using Swift Package Manager
|
||||||
|
|
||||||
1. Open the project in Xcode:
|
1. Open the project in Xcode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
open Package.swift
|
open Package.swift
|
||||||
|
|||||||
@@ -32,10 +32,6 @@
|
|||||||
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 */; };
|
||||||
@@ -209,8 +205,6 @@
|
|||||||
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>"; };
|
||||||
@@ -489,7 +483,6 @@
|
|||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||||
@@ -526,7 +519,6 @@
|
|||||||
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 */,
|
||||||
@@ -779,7 +771,6 @@
|
|||||||
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 */,
|
||||||
@@ -839,7 +830,6 @@
|
|||||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
|
||||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||||
@@ -876,7 +866,6 @@
|
|||||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
|
||||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||||
@@ -899,7 +888,6 @@
|
|||||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
|
||||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||||
|
|||||||
@@ -87,28 +87,4 @@ enum Geohash {
|
|||||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
return (lat, lon)
|
return (lat, lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a geohash into its latitude and longitude bounds.
|
|
||||||
/// - Parameter geohash: Base32 geohash string.
|
|
||||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
|
||||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
|
|
||||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
|
||||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
|
||||||
|
|
||||||
var isEven = true
|
|
||||||
for ch in geohash.lowercased() {
|
|
||||||
guard let cd = base32Map[ch] else { continue }
|
|
||||||
for mask in [16, 8, 4, 2, 1] {
|
|
||||||
if isEven {
|
|
||||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
|
||||||
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
|
||||||
} else {
|
|
||||||
let mid = (latInterval.0 + latInterval.1) / 2
|
|
||||||
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
|
||||||
}
|
|
||||||
isEven.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
|
||||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
|
||||||
final class GeohashBookmarksStore: ObservableObject {
|
|
||||||
static let shared = GeohashBookmarksStore()
|
|
||||||
|
|
||||||
@Published private(set) var bookmarks: [String] = []
|
|
||||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
|
||||||
|
|
||||||
private let storeKey = "locationChannel.bookmarks"
|
|
||||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
|
||||||
private var membership: Set<String> = []
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
private let geocoder = CLGeocoder()
|
|
||||||
private var resolving: Set<String> = []
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
|
||||||
return membership.contains(Self.normalize(geohash))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggle(_ geohash: String) {
|
|
||||||
let gh = Self.normalize(geohash)
|
|
||||||
if membership.contains(gh) {
|
|
||||||
remove(gh)
|
|
||||||
} else {
|
|
||||||
add(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(_ geohash: String) {
|
|
||||||
let gh = Self.normalize(geohash)
|
|
||||||
guard !gh.isEmpty else { return }
|
|
||||||
guard !membership.contains(gh) else { return }
|
|
||||||
bookmarks.insert(gh, at: 0)
|
|
||||||
membership.insert(gh)
|
|
||||||
persist()
|
|
||||||
// Resolve and persist a friendly name once when added
|
|
||||||
resolveNameIfNeeded(for: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(_ geohash: String) {
|
|
||||||
let gh = Self.normalize(geohash)
|
|
||||||
guard membership.contains(gh) else { return }
|
|
||||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
|
||||||
membership.remove(gh)
|
|
||||||
// Clean up stored name to avoid stale cache growth
|
|
||||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Persistence
|
|
||||||
private func load() {
|
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
|
||||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
|
||||||
var seen = Set<String>()
|
|
||||||
var list: [String] = []
|
|
||||||
for raw in arr {
|
|
||||||
let gh = Self.normalize(raw)
|
|
||||||
guard !gh.isEmpty else { continue }
|
|
||||||
if !seen.contains(gh) {
|
|
||||||
seen.insert(gh)
|
|
||||||
list.append(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bookmarks = list
|
|
||||||
membership = seen
|
|
||||||
}
|
|
||||||
// Load any saved names
|
|
||||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
|
||||||
bookmarkNames = dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persist() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
|
||||||
UserDefaults.standard.set(data, forKey: storeKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistNames() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
|
||||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
private static func normalize(_ s: String) -> String {
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
return s
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: "#", with: "")
|
|
||||||
.filter { allowed.contains($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Name Resolution
|
|
||||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
|
||||||
func resolveNameIfNeeded(for geohash: String) {
|
|
||||||
let gh = Self.normalize(geohash)
|
|
||||||
guard !gh.isEmpty else { return }
|
|
||||||
if bookmarkNames[gh] != nil { return }
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if resolving.contains(gh) { return }
|
|
||||||
resolving.insert(gh)
|
|
||||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
|
||||||
if gh.count <= 2 {
|
|
||||||
let b = Geohash.decodeBounds(gh)
|
|
||||||
let pts: [CLLocation] = [
|
|
||||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
|
||||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
|
||||||
]
|
|
||||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
|
||||||
} else {
|
|
||||||
let center = Geohash.decodeCenter(gh)
|
|
||||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
defer { self.resolving.remove(gh) }
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
|
||||||
if let name = name, !name.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = name
|
|
||||||
self.persistNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
|
||||||
var uniqueAdmins = OrderedSet<String>()
|
|
||||||
var idx = 0
|
|
||||||
func step() {
|
|
||||||
if idx >= points.count {
|
|
||||||
// Compose up to 2 names joined by ' and '
|
|
||||||
let finalName: String? = {
|
|
||||||
let names = uniqueAdmins.array
|
|
||||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
|
||||||
return names.first
|
|
||||||
}()
|
|
||||||
if let finalName = finalName, !finalName.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = finalName
|
|
||||||
self.persistNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.resolving.remove(gh)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let loc = points[idx]
|
|
||||||
idx += 1
|
|
||||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
|
||||||
guard self != nil else { return }
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
uniqueAdmins.insert(admin)
|
|
||||||
} else if let country = pm.country, !country.isEmpty {
|
|
||||||
uniqueAdmins.insert(country)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Proceed to next point
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal ordered-set for stable joining
|
|
||||||
private struct OrderedSet<Element: Hashable> {
|
|
||||||
private var set: Set<Element> = []
|
|
||||||
private(set) var array: [Element] = []
|
|
||||||
mutating func insert(_ element: Element) {
|
|
||||||
if set.insert(element).inserted { array.append(element) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
|
||||||
switch len {
|
|
||||||
case 0...2:
|
|
||||||
// Prefer administrative area if available at this coarse level
|
|
||||||
return pm.administrativeArea ?? pm.country
|
|
||||||
case 3...4:
|
|
||||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
|
||||||
case 5:
|
|
||||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
|
||||||
case 6...7:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
|
||||||
default:
|
|
||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Testing-only reset helper
|
|
||||||
func _resetForTesting() {
|
|
||||||
bookmarks.removeAll()
|
|
||||||
membership.removeAll()
|
|
||||||
bookmarkNames.removeAll()
|
|
||||||
persist()
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -50,8 +50,10 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
teleportedSet = Set(arr)
|
teleportedSet = Set(arr)
|
||||||
}
|
}
|
||||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
// Initialize teleported flag from persisted state if a location channel is selected
|
||||||
// This avoids showing teleported for in-region channels during cold start.
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
@@ -59,15 +61,6 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
status = CLLocationManager.authorizationStatus()
|
status = CLLocationManager.authorizationStatus()
|
||||||
}
|
}
|
||||||
updatePermissionState(from: status)
|
updatePermissionState(from: status)
|
||||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
|
||||||
switch status {
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
break // will compute from location
|
|
||||||
default:
|
|
||||||
if case .location(let ch) = selectedChannel {
|
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
@@ -136,21 +129,7 @@ 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.
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,25 +202,14 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.availableChannels = result
|
self.availableChannels = result
|
||||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||||
switch self.selectedChannel {
|
switch self.selectedChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
self.teleported = false
|
self.teleported = false
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||||
if inRegional {
|
self.teleported = persisted || (currentGH != ch.geohash)
|
||||||
self.teleported = false
|
|
||||||
// Clear persisted teleport flag if present
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
|
||||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,11 +383,6 @@ 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] = []
|
||||||
@@ -591,11 +586,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,41 +611,21 @@ 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
|
// Background: keep sampling nearby geohashes for notifications even when sheet is closed
|
||||||
LocationChannelManager.shared.$availableChannels
|
LocationChannelManager.shared.$availableChannels
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channels in
|
.sink { [weak self] channels in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let regional = channels.map { $0.geohash }
|
let ghs = channels.map { $0.geohash }
|
||||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
|
||||||
let union = Array(Set(regional).union(bookmarks))
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.beginGeohashSampling(for: union)
|
self.beginGeohashSampling(for: ghs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
// Kick off initial sampling if we already have channels
|
||||||
// Also observe bookmark changes to update sampling
|
if !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
GeohashBookmarksStore.shared.$bookmarks
|
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||||
.receive(on: DispatchQueue.main)
|
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
|
||||||
.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
|
// Refresh channels once when authorized to seed sampling
|
||||||
LocationChannelManager.shared.$permissionState
|
LocationChannelManager.shared.$permissionState
|
||||||
@@ -665,26 +635,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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()
|
||||||
|
|
||||||
@@ -822,19 +772,10 @@ 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
|
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||||
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]) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = event.content
|
||||||
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(
|
||||||
@@ -1276,9 +1217,7 @@ 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) {
|
||||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
guard !content.isEmpty else { return }
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if content.hasPrefix("/") {
|
if content.hasPrefix("/") {
|
||||||
@@ -1298,7 +1237,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Parse mentions from the content (use original content for user intent)
|
// Parse mentions from the content
|
||||||
let mentions = parseMentions(from: content)
|
let mentions = parseMentions(from: content)
|
||||||
|
|
||||||
// Add message to local display
|
// Add message to local display
|
||||||
@@ -1313,7 +1252,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: displaySender,
|
sender: displaySender,
|
||||||
content: trimmed,
|
content: content,
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -1358,7 +1297,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: trimmed,
|
content: content,
|
||||||
geohash: ch.geohash,
|
geohash: ch.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
@@ -1378,10 +1317,7 @@ 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
|
||||||
// Only when not in our regional set (and regional list is known)
|
if LocationChannelManager.shared.teleported {
|
||||||
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)",
|
||||||
@@ -1412,38 +1348,16 @@ 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 }
|
||||||
// Deduplicate by ID while preserving order (from oldest to newest)
|
if arr.count != before { geoTimelines[ch.geohash] = arr }
|
||||||
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 {
|
||||||
@@ -1461,18 +1375,14 @@ 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 only when truly teleported
|
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
||||||
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)
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
if LocationChannelManager.shared.teleported {
|
||||||
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)"
|
||||||
@@ -1501,19 +1411,10 @@ 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
|
Task { @MainActor in
|
||||||
let isSelf: Bool = {
|
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||||
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||||
return my.publicKeyHex.lowercased() == key
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
if !isSelf {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
|
||||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
|
||||||
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
|
||||||
@@ -1865,27 +1766,17 @@ 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
|
// Notify on new message activity in this geohash (sampling across channels)
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
// Respect geohash blocks
|
// Respect geohash blocks
|
||||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||||
// Skip self identity for this geohash
|
// Skip self identity for this geohash
|
||||||
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
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
|
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||||
|
// Suppress only when app is active AND we're already in this same geohash channel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if UIApplication.shared.applicationState == .active {
|
if UIApplication.shared.applicationState == .active {
|
||||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||||
@@ -1908,30 +1799,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}()
|
}()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.lastGeoNotificationAt[gh] = now
|
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)
|
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2611,17 +2478,6 @@ 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 *"
|
||||||
|
|
||||||
@@ -2641,7 +2497,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show local notification immediately as system message (only in chat)
|
// Show local notification immediately as system message
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -2692,7 +2548,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show local notification immediately as system message (only in chat)
|
// Show local notification immediately as system message
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -3077,10 +2933,8 @@ 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 nsContent = contentText as NSString
|
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
let nsLen = nsContent.length
|
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
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)] = []
|
||||||
@@ -3097,14 +2951,12 @@ 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()
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
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
|
||||||
@@ -3122,7 +2974,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
|
|
||||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
lastEndIndex = range.upperBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3199,12 +3051,9 @@ 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: nsLen)) > 0
|
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 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()
|
||||||
@@ -3222,6 +3071,8 @@ 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")
|
||||||
@@ -3301,19 +3152,17 @@ 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()
|
beforeStyle.foregroundColor = baseColor
|
||||||
beforeStyle.foregroundColor = baseColor
|
beforeStyle.font = isSelf
|
||||||
beforeStyle.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)
|
if isMentioned {
|
||||||
if isMentioned {
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
beforeStyle.font = beforeStyle.font?.bold()
|
|
||||||
}
|
|
||||||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
||||||
}
|
}
|
||||||
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add styled match
|
// Add styled match
|
||||||
@@ -3421,10 +3270,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Advance lastEnd safely in case of overlaps
|
lastEnd = nsRange.upperBound
|
||||||
if lastEnd < nsRange.upperBound {
|
|
||||||
lastEnd = nsRange.upperBound
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3522,23 +3368,19 @@ 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 nsContent = contentText as NSString
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
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()
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
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
|
||||||
@@ -3548,7 +3390,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))
|
||||||
|
|
||||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
lastEndIndex = range.upperBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4446,12 +4288,11 @@ 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 normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let publicMentions = parseMentions(from: content)
|
||||||
let publicMentions = parseMentions(from: normalized)
|
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
content: normalized,
|
content: content,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -4625,10 +4466,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rising-edge only: previously zero peers, now > 0 peers
|
// Check if we have new mesh peers we haven't seen recently
|
||||||
let currentPeerSet = Set(meshPeers)
|
let currentPeerSet = Set(meshPeers)
|
||||||
let hadNone = self.recentlySeenPeers.isEmpty
|
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
||||||
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
// Send notification if:
|
||||||
|
// 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
|
||||||
@@ -4637,14 +4482,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No peers — immediately reset to allow next rising-edge to notify
|
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
||||||
self.hasNotifiedNetworkAvailable = false
|
if self.networkResetTimer == nil {
|
||||||
self.recentlySeenPeers.removeAll()
|
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
||||||
if self.networkResetTimer != nil {
|
guard let self = self else { return }
|
||||||
self.networkResetTimer?.invalidate()
|
self.hasNotifiedNetworkAvailable = false
|
||||||
self.networkResetTimer = nil
|
self.recentlySeenPeers.removeAll()
|
||||||
|
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
|
||||||
@@ -4746,9 +4593,7 @@ 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 nsContent = content as NSString
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||||
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()
|
||||||
@@ -5815,12 +5660,9 @@ 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
|
arr.append(finalMessage)
|
||||||
if !arr.contains(where: { $0.id == finalMessage.id }) {
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
arr.append(finalMessage)
|
geoTimelines[gh] = arr
|
||||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
|
||||||
geoTimelines[gh] = arr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5835,13 +5677,38 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard channelMatches else { return }
|
guard channelMatches else { return }
|
||||||
|
|
||||||
// Removed background nudge notification for generic "new chats!"
|
// Background nudge: notify on new activity after inactivity threshold in current channel
|
||||||
|
#if os(iOS)
|
||||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
if UIApplication.shared.applicationState != .active {
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
let channelKey: String = {
|
||||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
switch activeChannel {
|
||||||
enqueuePublic(finalMessage)
|
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!"
|
||||||
|
if case .location(let ch) = activeChannel {
|
||||||
|
// Attach deep link to open this geohash directly
|
||||||
|
NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body)
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
enqueuePublic(finalMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@@ -165,8 +164,6 @@ 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 },
|
||||||
@@ -281,10 +278,8 @@ 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(filteredItems, id: \.uiID) { item in
|
ForEach(items, 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
|
||||||
@@ -464,13 +459,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
// Do not mark teleported when opening a geohash that is in our regional set.
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
// 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.select(ChannelID.location(ch))
|
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
@@ -1123,15 +1112,6 @@ struct ContentView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Open unread private chat")
|
.accessibilityLabel("Open unread private chat")
|
||||||
}
|
}
|
||||||
// Bookmark toggle for current geohash (not shown for mesh)
|
|
||||||
if case .location(let ch) = locationManager.selectedChannel {
|
|
||||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
|
||||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
|
||||||
}
|
|
||||||
// Location channels button '#'
|
// Location channels button '#'
|
||||||
Button(action: { showLocationChannelsSheet = true }) {
|
Button(action: { showLocationChannelsSheet = true }) {
|
||||||
let badgeText: String = {
|
let badgeText: String = {
|
||||||
@@ -1186,13 +1166,6 @@ 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import AppKit
|
|||||||
struct LocationChannelsSheet: View {
|
struct LocationChannelsSheet: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@@ -99,7 +98,7 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
private var channelList: some View {
|
private var channelList: some View {
|
||||||
List {
|
List {
|
||||||
// Mesh option first (no bookmark)
|
// Mesh option first
|
||||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
@@ -113,21 +112,7 @@ struct LocationChannelsSheet: View {
|
|||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||||
channelRow(
|
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||||
title: geohashTitleWithCount(for: channel),
|
|
||||||
subtitlePrefix: subtitlePrefix,
|
|
||||||
subtitleName: namePart,
|
|
||||||
isSelected: isSelected(channel),
|
|
||||||
titleBold: highlight,
|
|
||||||
trailingAccessory: {
|
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.leading, 8)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
manager.markTeleported(for: channel.geohash, false)
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
@@ -203,48 +188,6 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmarked geohashes
|
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("bookmarked")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
|
||||||
let level = levelForLength(gh.count)
|
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
|
||||||
let coverage = coverageString(forPrecision: gh.count)
|
|
||||||
let subtitle = "#\(gh) • \(coverage)"
|
|
||||||
let name = bookmarks.bookmarkNames[gh]
|
|
||||||
channelRow(
|
|
||||||
title: geohashHashTitleWithCount(gh),
|
|
||||||
subtitlePrefix: subtitle,
|
|
||||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
|
||||||
isSelected: isSelected(channel),
|
|
||||||
trailingAccessory: {
|
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.leading, 8)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// For bookmarked selection, mark teleported based on regional membership
|
|
||||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
|
||||||
if !inRegional && !manager.availableChannels.isEmpty {
|
|
||||||
manager.markTeleported(for: gh, true)
|
|
||||||
} else {
|
|
||||||
manager.markTeleported(for: gh, false)
|
|
||||||
}
|
|
||||||
manager.select(ChannelID.location(channel))
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -277,24 +220,14 @@ struct LocationChannelsSheet: View {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||||
private func channelRow(
|
Button(action: action) {
|
||||||
title: String,
|
HStack {
|
||||||
subtitlePrefix: String,
|
VStack(alignment: .leading) {
|
||||||
subtitleName: String? = nil,
|
// Render title with smaller font for trailing count in parentheses
|
||||||
subtitleNameBold: Bool = false,
|
let parts = splitTitleAndCount(title)
|
||||||
isSelected: Bool,
|
HStack(spacing: 4) {
|
||||||
titleColor: Color? = nil,
|
Text(parts.base)
|
||||||
titleBold: Bool = false,
|
|
||||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
|
||||||
action: @escaping () -> Void
|
|
||||||
) -> some View {
|
|
||||||
HStack(alignment: .center, spacing: 8) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
// Render title with smaller font for trailing count in parentheses
|
|
||||||
let parts = splitTitleAndCount(title)
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(parts.base)
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.fontWeight(titleBold ? .bold : .regular)
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
.foregroundColor(titleColor ?? Color.primary)
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
@@ -304,17 +237,20 @@ struct LocationChannelsSheet: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subtitleFull: String = {
|
HStack(spacing: 0) {
|
||||||
if let name = subtitleName, !name.isEmpty {
|
Text(subtitlePrefix)
|
||||||
return subtitlePrefix + " • " + name
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
if let name = subtitleName {
|
||||||
|
Text(" • ")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return subtitlePrefix
|
|
||||||
}()
|
|
||||||
Text(subtitleFull)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
@@ -322,11 +258,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]"
|
||||||
@@ -355,19 +291,12 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||||
let noun = count == 1 ? "person" : "people"
|
let noun = count == 1 ? "person" : "people"
|
||||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
|
||||||
// Bookmarked list: show the #geohash as the main label
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
let noun = count == 1 ? "person" : "people"
|
|
||||||
return "#\(geohash) [\(count) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validateGeohash(_ s: String) -> Bool {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
|
||||||
let storeKey = "locationChannel.bookmarks"
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
// Clear persisted state before each test
|
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Clean after each test
|
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testToggleAndNormalize() {
|
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
// Start clean
|
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
|
||||||
|
|
||||||
// Add with mixed case and hash prefix
|
|
||||||
store.toggle("#U4PRUY")
|
|
||||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
|
||||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
|
||||||
|
|
||||||
// Toggling again removes
|
|
||||||
store.toggle("u4pruy")
|
|
||||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPersistenceWritten() throws {
|
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
store.toggle("ezs42")
|
|
||||||
store.toggle("u4pruy")
|
|
||||||
|
|
||||||
// Verify persisted JSON contains both (order not enforced here)
|
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
|
||||||
XCTFail("No persisted data found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
|
||||||
XCTAssertTrue(arr.contains("ezs42"))
|
|
||||||
XCTAssertTrue(arr.contains("u4pruy"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user