Compare commits

..
Author SHA1 Message Date
jack 4d4c596a17 UI: diversify peer colors; smarter geo notifications; 21m live location
- 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.
2025-08-26 18:39:48 +02:00
15 changed files with 248 additions and 1379 deletions
+18 -68
View File
@@ -2,108 +2,59 @@
## 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)
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!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
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
## Features
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) 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
- **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
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)
- **Local Communication**: Direct peer-to-peer within Bluetooth range
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
- **No Internet Required**: Works completely offline in disaster scenarios
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
- **Automatic Discovery**: Peer discovery and connection management
- **Adaptive Power**: Battery-optimized duty cycling
### Nostr Protocol (Internet)
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
### Channel Types
#### `mesh #bluetooth`
- **Transport**: Bluetooth Low Energy mesh network
- **Scope**: Local devices within multi-hop range
- **Internet**: Not required
- **Use Case**: Offline communication, protests, disasters, remote areas
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
- **Transport**: Nostr protocol over internet
- **Scope**: Geographic areas defined by geohash precision
- `block` (7 chars): City block level
- `neighborhood` (6 chars): District/neighborhood
- `city` (5 chars): City level
- `province` (4 chars): State/province
- `region` (2 chars): Country/large region
- **Internet**: Required (connects to Nostr relays)
- **Use Case**: Location-based community chat, local events, regional discussions
### Direct Message Routing
Private messages use **intelligent transport selection**:
1. **Bluetooth First** (preferred when available)
- Direct connection with established Noise session
- Fastest and most private option
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- Routes through global relay network
3. **Smart Queuing** (when neither available)
- Messages queued until transport becomes available
- Automatic delivery when connection established
### Mesh Networking
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management
- Adaptive duty cycling for battery optimization
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
## Setup
### Option 1: Using XcodeGen (Recommended)
1. Install XcodeGen if you haven't already:
```bash
brew install xcodegen
```
2. Generate the Xcode project:
```bash
cd bitchat
xcodegen generate
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 2: Using Swift Package Manager
1. Open the project in Xcode:
```bash
cd bitchat
open Package.swift
@@ -134,5 +84,5 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 4: just
Want to try this on macos: `just run` will set it up and run from source.
Want to try this on macos: `just run` will set it up and run from source.
Run `just clean` afterwards to restore things to original state for mobile app building and development.
-36
View File
@@ -7,14 +7,6 @@
objects = {
/* Begin PBXBuildFile section */
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
@@ -40,10 +32,6 @@
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -205,10 +193,6 @@
/* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
04657AB42E633C6A002D5BBE /* NostrPoW.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoW.swift; sourceTree = "<group>"; };
04657AB72E633C76002D5BBE /* PowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowPolicy.swift; sourceTree = "<group>"; };
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiningAnimation.swift; sourceTree = "<group>"; };
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoWTests.swift; sourceTree = "<group>"; };
0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = "<group>"; };
047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = "<group>"; };
047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
@@ -221,8 +205,6 @@
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -463,7 +445,6 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */,
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
@@ -503,8 +484,6 @@
isa = PBXGroup;
children = (
D69A18D27F9A565FD6041E12 /* Info.plist */,
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -540,8 +519,6 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
04657AB72E633C76002D5BBE /* PowPolicy.swift */,
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */,
@@ -565,7 +542,6 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
04657AB42E633C6A002D5BBE /* NostrPoW.swift */,
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
@@ -795,7 +771,6 @@
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
@@ -808,18 +783,15 @@
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */,
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
);
@@ -858,7 +830,6 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
@@ -871,18 +842,15 @@
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */,
047502882E5416250083520F /* LocationChannel.swift in Sources */,
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
);
@@ -897,9 +865,7 @@
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
@@ -921,9 +887,7 @@
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
-96
View File
@@ -1,96 +0,0 @@
import Foundation
import CryptoKit
import Security
import Darwin
/// NIP-13 Proof-of-Work utilities for Nostr events.
/// - Counts leading zero bits and mines a nonce tag to meet a target.
enum NostrPoW {
/// Table of leading zero bit counts for all 256 byte values.
private static let lzTable: [UInt8] = {
(0...255).map { v -> UInt8 in
var x = UInt8(v)
if x == 0 { return 8 }
var n: UInt8 = 0
while (x & 0x80) == 0 {
n &+= 1
x <<= 1
}
return n
}
}()
/// Count leading zero bits in a 32-byte hash.
static func leadingZeroBits(_ data: Data) -> Int {
var total = 0
for b in data {
let lz = Int(lzTable[Int(b)])
total += lz
if lz != 8 { break }
}
return total
}
/// Compute Nostr event id hash (SHA-256 over canonical serialization) for given fields.
private static func eventIDHash(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> (hex: String, data: Data) {
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let digest = SHA256.hash(data: json)
let data = Data(digest)
let hex = data.map { String(format: "%02x", $0) }.joined()
return (hex, data)
}
/// Mine a NIP-13 nonce tag that satisfies the target leading-zero bits.
/// - Parameters:
/// - pubkey: x-only hex pubkey (64-hex chars)
/// - createdAt: seconds since epoch
/// - kind: event kind
/// - baseTags: existing tags (e.g., [ ["g", geohash], ["n", nickname], ... ])
/// - content: event content
/// - targetBits: required leading zero bits
/// - startNonce: optional starting nonce (random if nil)
/// - Returns: (nonce, idHex)
static func mine(pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetBits: Int,
startNonce: UInt64? = nil) -> (nonce: UInt64, idHex: String) {
var nonce: UInt64 = startNonce ?? randomNonce()
// Build a local tags buffer once to avoid reallocating unrelated tags
var tags = baseTags
tags.append(["nonce", "0", String(targetBits)])
var iter: UInt64 = 0
while true {
var solved = false
var idHexOut = ""
// Use an autorelease pool periodically to keep memory stable on iOS
autoreleasepool {
// Update nonce tag value (second element)
tags[tags.count - 1][1] = String(nonce)
let (idHex, idData) = eventIDHash(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content)
if leadingZeroBits(idData) >= targetBits {
solved = true
idHexOut = idHex
}
}
if solved {
return (nonce, idHexOut)
}
nonce &+= 1
iter &+= 1
if iter & 0x3FFF == 0 { /* yield every ~16k iterations */ _ = sched_yield() }
}
}
private static func randomNonce() -> UInt64 {
var n: UInt64 = 0
withUnsafeMutableBytes(of: &n) { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, ptr.count, ptr.baseAddress!)
}
return n
}
}
+2 -11
View File
@@ -409,8 +409,6 @@ class BitchatMessage: Codable {
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Mining state for Nostr geohash PoW (UI hint: hide timestamp, animate)
var isMiningPow: Bool? // default nil/false
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
@@ -423,18 +421,13 @@ class BitchatMessage: Codable {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
func clearCachedFormattedText() {
_cachedFormattedText.removeAll()
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
case isMiningPow
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil, isMiningPow: Bool? = nil) {
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
@@ -446,7 +439,6 @@ class BitchatMessage: Codable {
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
self.isMiningPow = isMiningPow
}
}
@@ -463,8 +455,7 @@ extension BitchatMessage: Equatable {
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus &&
(lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false)
lhs.deliveryStatus == rhs.deliveryStatus
}
}
-24
View File
@@ -87,28 +87,4 @@ enum Geohash {
let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon)
}
/// Decodes a geohash into its latitude and longitude bounds.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (latMin, latMax, lonMin, lonMax)
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
}
@@ -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
}
+9 -41
View File
@@ -50,8 +50,10 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
// This avoids showing teleported for in-region channels during cold start.
// Initialize teleported flag from persisted state if a location channel is selected
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
@@ -59,15 +61,6 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API
@@ -136,21 +129,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .mesh:
self.teleported = false
case .location(let ch):
// If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
@@ -223,25 +202,14 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
}
Task { @MainActor in
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 {
case .mesh:
self.teleported = false
case .location(let ch):
// Membership check using freshly computed regional channels; avoids precision/rename drift
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
let persisted = self.teleportedSet.contains(ch.geohash)
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
self.teleported = persisted || (currentGH != ch.geohash)
}
}
}
-17
View File
@@ -1,17 +0,0 @@
import Foundation
/// Simple default policy for NIP-13 difficulty on geohash chats.
/// Progressive by geohash precision; adjustable later by live activity.
enum PowPolicy {
/// Determine target bits for a given geohash string.
/// Clamped to a sane range to keep UX responsive on phones.
static func requiredBits(forGeohash geohash: String) -> Int {
let precision = geohash.count
// Start at 13 and go down with higher precision (smaller areas)
switch precision {
case ...5: return 16
case 6: return 15
default: return 14
}
}
}
+174 -488
View File
@@ -382,11 +382,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@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
// Persist mesh public timeline across channel switches
@@ -591,11 +586,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if connected {
Task { @MainActor in
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)
}
// 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
.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))
let ghs = channels.map { $0.geohash }
Task { @MainActor in
self.beginGeohashSampling(for: union)
self.beginGeohashSampling(for: ghs)
}
}
.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) }
}
// Kick off initial sampling if we already have channels
if !LocationChannelManager.shared.availableChannels.isEmpty {
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
}
// Refresh channels once when authorized to seed sampling
LocationChannelManager.shared.$permissionState
@@ -664,26 +634,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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
NotificationService.shared.requestAuthorization()
@@ -796,17 +746,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id)
// PoW filter: require local difficulty for this geohash
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
let ghValue = ghFromTag ?? ch.geohash
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
let required = PowPolicy.requiredBits(forGeohash: ghValue)
if powBits < required {
// Drop low PoW event
return
}
}
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
@@ -824,11 +763,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey with observed PoW bits
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
}
// Update participants last-seen for this pubkey
self.recordGeoParticipant(pubkeyHex: event.pubkey)
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
@@ -836,19 +772,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
})
if hasTeleportTag {
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 content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
let content = event.content
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = self.parseMentions(from: content)
let msg = BitchatMessage(
@@ -1290,9 +1217,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Routes to private chat if one is selected, otherwise broadcasts
@MainActor
func sendMessage(_ content: String) {
// Ignore messages that are empty or whitespace-only to prevent blank lines
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard !content.isEmpty else { return }
// Check for commands
if content.hasPrefix("/") {
@@ -1312,7 +1237,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
}
} else {
// Parse mentions from the content (use original content for user intent)
// Parse mentions from the content
let mentions = parseMentions(from: content)
// Add message to local display
@@ -1327,18 +1252,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let message = BitchatMessage(
sender: displaySender,
content: trimmed,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions,
deliveryStatus: nil,
isMiningPow: {
if case .location(_) = activeChannel { return true } else { return nil }
}()
mentions: mentions.isEmpty ? nil : mentions
)
// Add to main messages immediately for user feedback
@@ -1371,42 +1292,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
if case .location(let ch) = activeChannel {
// Send to geohash channel via Nostr ephemeral with PoW
let msgID = message.id
let contentToSend = trimmed
Task {@MainActor in
// Send to geohash channel via Nostr ephemeral
Task { @MainActor in
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let createdAt = Int(message.timestamp.timeIntervalSince1970)
var baseTags = [["g", ch.geohash]]
if !self.nickname.isEmpty { baseTags.append(["n", self.nickname]) }
if LocationChannelManager.shared.teleported { baseTags.append(["t", "teleport"]) }
let bits = PowPolicy.requiredBits(forGeohash: ch.geohash)
// Mine off the main thread
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
DispatchQueue.global(qos: .utility).async {
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
createdAt: createdAt,
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
baseTags: baseTags,
content: contentToSend,
targetBits: bits)
cont.resume(returning: (res.nonce, res.idHex))
}
}
// Build final event with nonce tag and sign
var tags = baseTags
tags.append(["nonce", String(nonce), String(bits)])
var event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: message.timestamp,
kind: .ephemeralEvent,
tags: tags,
content: contentToSend
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: ch.geohash,
senderIdentity: identity,
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
let schnorrKey = try identity.schnorrSigningKey()
event = try event.sign(with: schnorrKey)
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
@@ -1416,25 +1312,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant with PoW record
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, geohash: ch.geohash, powBits: powBits)
} else {
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
}
SecureLogger.log("Geo: sent geo event id=\(event.id.prefix(12))… bits=\(bits) nonce=\(String(nonce))", category: SecureLogger.session, level: .debug)
// Reveal timestamp / stop animation
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
self.messages[idx].isMiningPow = false
self.messages[idx].clearCachedFormattedText()
// Trigger UI refresh so timestamp appears
self.objectWillChange.send()
}
// If teleported and outside regional set, mark self teleported
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
category: SecureLogger.session, level: .debug)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
if LocationChannelManager.shared.teleported {
let key = identity.publicKeyHex.lowercased()
self.teleportedGeo = self.teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
@@ -1443,11 +1326,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} catch {
SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error)
self.addSystemMessage("failed to send to location channel")
if let idx = self.messages.firstIndex(where: { $0.id == msgID }) {
self.messages[idx].isMiningPow = false
self.messages[idx].clearCachedFormattedText()
self.objectWillChange.send()
}
}
}
} else {
@@ -1470,38 +1348,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch channel {
case .mesh:
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()
geohashPeople = []
teleportedGeo.removeAll()
case .location(let ch):
// Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? []
let before = arr.count
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
// 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
if arr.count != before { geoTimelines[ch.geohash] = 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
if let sub = geoSubscriptionID {
@@ -1519,18 +1375,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard case .location(let ch) = channel else { return }
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) {
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased()
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
if LocationChannelManager.shared.teleported {
let key = id.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
category: SecureLogger.session, level: .info)
} else {
teleportedGeo.remove(key)
}
}
let subID = "geo-\(ch.geohash)"
@@ -1549,16 +1401,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Deduplicate
if self.processedNostrEvents.contains(event.id) { return }
self.recordProcessedEvent(event.id)
// PoW filter for geohash events
let ghFromTag: String? = event.tags.first(where: { $0.first?.lowercased() == "g" && $0.count >= 2 })?.dropFirst().first
let ghValue = ghFromTag ?? ch.geohash
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
let required = PowPolicy.requiredBits(forGeohash: ghValue)
if powBits < required {
return
}
}
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
@@ -1569,19 +1411,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
})
if hasTeleportTag {
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
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)
}
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
@@ -1605,11 +1438,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.nostrKeyMapping[key16] = event.pubkey
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
self.nostrKeyMapping[key8] = event.pubkey
// Update participants last-seen for this pubkey with observed PoW bits
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: ch.geohash, powBits: powBits)
}
// Update participants last-seen for this pubkey
self.recordGeoParticipant(pubkeyHex: event.pubkey)
let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content
@@ -1794,24 +1624,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Track last observed PoW bits per participant per geohash
private var geoParticipantPowBits: [String: [String: Int]] = [:] // geohash -> (pubkeyLower -> bits)
private func recordGeoParticipant(pubkeyHex: String, geohash: String, powBits: Int?) {
let key = pubkeyHex.lowercased()
var map = geoParticipants[geohash] ?? [:]
map[key] = Date()
geoParticipants[geohash] = map
if let bits = powBits {
var powMap = geoParticipantPowBits[geohash] ?? [:]
powMap[key] = bits
geoParticipantPowBits[geohash] = powMap
}
if currentGeohash == geohash {
refreshGeohashPeople()
}
}
private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
@@ -1820,17 +1632,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
// Enforce PoW threshold for peer list as well
let required = PowPolicy.requiredBits(forGeohash: gh)
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
let powMap = geoParticipantPowBits[gh] ?? [:]
map = map.filter { (pub, _) in
if let bits = powMap[pub] { return bits >= required }
// Always allow self even if no PoW record yet
if let me = myHex, pub == me { return true }
// Otherwise, if no PoW info, hide until we see an acceptable event
return false
}
geoParticipants[gh] = map
// Build display list
let people = map
@@ -1861,17 +1662,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let required = PowPolicy.requiredBits(forGeohash: gh)
let myHex: String? = (try? NostrIdentityBridge.deriveIdentity(forGeohash: gh))?.publicKeyHex.lowercased()
let powMap = geoParticipantPowBits[gh] ?? [:]
let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
.filter { (pub, _) in
if let bits = powMap[pub] { return bits >= required }
if let me = myHex, pub == me { return true }
return false
}
let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen }
@@ -1973,38 +1766,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Enforce PoW for sampled geohashes too
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
let required = PowPolicy.requiredBits(forGeohash: gh)
if powBits < required { 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
if let idData = Data(hexString: event.id) {
let powBits = NostrPoW.leadingZeroBits(idData)
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh, powBits: powBits)
} else {
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
}
// Notify only on rising-edge: previously zero people, now someone sends a chat
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify on new message activity in this geohash (sampling across channels)
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
// Suppress only when app is active AND we're already in this same geohash channel
#if os(iOS)
if UIApplication.shared.applicationState == .active {
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
@@ -2027,30 +1799,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}()
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)
}
}
@@ -2730,17 +2478,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
@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
let screenshotMessage = "* \(nickname) took a screenshot *"
@@ -2760,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(
sender: "system",
content: "you took a screenshot",
@@ -2786,12 +2523,32 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
meshService.sendMessage(screenshotMessage, mentions: [])
case .location(let ch):
Task { @MainActor in
await mineAndSendGeohashEvent(content: screenshotMessage, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: screenshotMessage,
geohash: ch.geohash,
senderIdentity: identity,
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.log("❌ Failed to send geohash screenshot message: \(error)", category: SecureLogger.session, level: .error)
self.addSystemMessage("failed to send to location channel")
}
}
}
// Show local notification immediately as system message (only in chat)
// Show local notification immediately as system message
let localNotification = BitchatMessage(
sender: "system",
content: "you took a screenshot",
@@ -3176,10 +2933,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let nsContent = contentText as NSString
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)) ?? []
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
// Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = []
@@ -3196,14 +2951,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (matchRange, matchType) in allMatches {
// Add text before the match
if let range = Range(matchRange, in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
// Add the match with appropriate styling
@@ -3221,7 +2974,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
lastEndIndex = range.upperBound
}
}
@@ -3236,59 +2989,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return processedContent
}
// MARK: - Helpers for mining UI
@MainActor
func isSelfMessage(_ message: BitchatMessage) -> Bool {
let isSelf: Bool = {
if let spid = message.senderPeerID {
if spid == meshService.myPeerID { return true }
if case .location(let ch) = activeChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = spid.hasPrefix("nostr:") ? String(spid.dropFirst(6)) : String(spid.dropFirst(6))
return bare.lowercased().hasPrefix(id.publicKeyHex.lowercased().prefix(TransportConfig.nostrShortKeyDisplayLength))
}
}
return spid == meshService.myPeerID
}
if message.sender == nickname { return true }
if message.sender.hasPrefix(nickname + "#") { return true }
return false
}()
return isSelf
}
@MainActor
func baseColorForMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> Color {
let isDark = colorScheme == .dark
return isSelfMessage(message) ? .orange : peerColor(for: message, isDark: isDark)
}
@MainActor
func formatSenderPrefix(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf = isSelfMessage(message)
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
let (baseName, suffix) = splitSuffix(from: message.sender)
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
var prefix = AttributedString()
prefix.append(AttributedString("<@").mergingAttributes(senderStyle))
prefix.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
prefix.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
prefix.append(AttributedString("> ").mergingAttributes(senderStyle))
return prefix
}
@MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
@@ -3351,12 +3051,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// 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
// 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 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 {
var plainStyle = AttributeContainer()
@@ -3374,6 +3071,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector
let nsLen = content.count
let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
@@ -3453,19 +3152,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (range, type) in allMatches {
// Add text before match
if let nsRange = Range(range, in: content) {
if lastEnd < nsRange.lowerBound {
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
}
// Add styled match
@@ -3573,10 +3270,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
}
}
// Advance lastEnd safely in case of overlaps
if lastEnd < nsRange.upperBound {
lastEnd = nsRange.upperBound
}
lastEnd = nsRange.upperBound
}
}
@@ -3595,15 +3289,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Add timestamp at the end (hide while PoW mining)
let hideTimestamp = (message.isMiningPow ?? false)
if !hideTimestamp {
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
// Add timestamp at the end (smaller, light grey)
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
// System message
var contentStyle = AttributeContainer()
@@ -3677,23 +3368,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = contentText as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
var lastEndIndex = contentText.startIndex
for match in matches {
// Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
// Add the mention with highlight
@@ -3703,7 +3390,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
lastEndIndex = range.upperBound
}
}
@@ -4601,12 +4288,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized)
let publicMentions = parseMentions(from: content)
let msg = BitchatMessage(
id: UUID().uuidString,
sender: nickname,
content: normalized,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
@@ -4780,10 +4466,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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 hadNone = self.recentlySeenPeers.isEmpty
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
// 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.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
@@ -4792,14 +4482,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info)
}
} else {
// No peers immediately reset to allow next rising-edge to notify
self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll()
if self.networkResetTimer != nil {
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
// No peers - schedule a graceful reset to avoid refiring on brief drops
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.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
@@ -4901,9 +4593,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = content as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames()
@@ -5038,56 +4728,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func sendPublicRaw(_ content: String) {
if case .location(let ch) = activeChannel {
Task { @MainActor in
await mineAndSendGeohashEvent(content: content, geohash: ch.geohash, nickname: self.nickname, teleported: LocationChannelManager.shared.teleported, createdAt: Date())
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: ch.geohash,
senderIdentity: identity,
nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
if targetRelays.isEmpty {
NostrRelayManager.shared.sendEvent(event)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
} catch {
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
}
}
return
}
// Default: send over mesh
meshService.sendMessage(content, mentions: [])
}
// MARK: - Geohash PoW helper for system/raw messages
@MainActor
private func mineAndSendGeohashEvent(content: String, geohash: String, nickname: String, teleported: Bool, createdAt: Date) async {
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
let createdAtSecs = Int(createdAt.timeIntervalSince1970)
var baseTags = [["g", geohash]]
if !nickname.isEmpty { baseTags.append(["n", nickname]) }
if teleported { baseTags.append(["t", "teleport"]) }
let bits = PowPolicy.requiredBits(forGeohash: geohash)
let (nonce, _) = await withCheckedContinuation { (cont: CheckedContinuation<(UInt64, String), Never>) in
DispatchQueue.global(qos: .utility).async {
let res = NostrPoW.mine(pubkey: identity.publicKeyHex,
createdAt: createdAtSecs,
kind: NostrProtocol.EventKind.ephemeralEvent.rawValue,
baseTags: baseTags,
content: content,
targetBits: bits)
cont.resume(returning: (res.nonce, res.idHex))
}
}
var tags = baseTags
tags.append(["nonce", String(nonce), String(bits)])
var event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: createdAt,
kind: .ephemeralEvent,
tags: tags,
content: content
)
let key = try identity.schnorrSigningKey()
event = try event.sign(with: key)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
if relays.isEmpty {
NostrRelayManager.shared.sendEvent(event)
} else {
NostrRelayManager.shared.sendEvent(event, to: relays)
}
} catch {
SecureLogger.log("❌ GeoPoW: failed to send event: \(error)", category: SecureLogger.session, level: .error)
}
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@@ -5996,12 +5660,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash {
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)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
}
@@ -6016,13 +5677,38 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard channelMatches else { return }
// Removed background nudge notification for generic "new chats!"
// Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !messages.contains(where: { $0.id == finalMessage.id }) {
enqueuePublic(finalMessage)
// Background nudge: notify on new activity after inactivity threshold in current channel
#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!"
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)
}
}
+5 -90
View File
@@ -24,7 +24,6 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -165,8 +164,6 @@ struct ContentView: View {
}
.sheet(isPresented: $showAppInfo) {
AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
}
.sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil },
@@ -281,10 +278,8 @@ struct ContentView: View {
}
}()
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
VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned
@@ -303,21 +298,10 @@ struct ContentView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme)
let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme)
let baseColor = viewModel.baseColorForMessage(message, colorScheme: colorScheme)
let isSelfMsg = viewModel.isSelfMessage(message)
if (message.isMiningPow ?? false) {
// Single Text combines prefix + animated body, so wrapping matches final layout
CombinedMiningText(prefix: prefixAttr, original: message.content, color: baseColor, isSelf: isSelfMsg)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(formatted)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
}
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
@@ -475,13 +459,7 @@ struct ContentView: View {
}
let level = levelForLength(gh.count)
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))
}
.onTapGesture(count: 3) {
@@ -1134,15 +1112,6 @@ struct ContentView: View {
.buttonStyle(.plain)
.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 '#'
Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = {
@@ -1197,13 +1166,6 @@ struct ContentView: View {
.padding(.horizontal, 12)
.sheet(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))
}
@@ -1398,53 +1360,6 @@ struct ContentView: View {
}
// MARK: - Mining UI helpers (local to ensure inclusion in target)
fileprivate struct CombinedMiningText: View {
let prefix: AttributedString
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(combinedAttributed)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear { timer?.invalidate(); timer = nil }
}
private var combinedAttributed: AttributedString {
var res = AttributedString()
res.append(prefix)
var style = AttributeContainer()
style.foregroundColor = color
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
res.append(AttributedString(display).mergingAttributes(style))
return res
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
// MARK: - Helper Views
// Rounded payment chip button
+27 -98
View File
@@ -8,7 +8,6 @@ import AppKit
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = ""
@@ -99,7 +98,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View {
List {
// Mesh option first (no bookmark)
// Mesh option first
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh)
isPresented = false
@@ -113,21 +112,7 @@ struct LocationChannelsSheet: View {
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(
title: geohashTitleWithCount(for: channel),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
// Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel))
@@ -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
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: {
@@ -277,24 +220,14 @@ struct LocationChannelsSheet: View {
return false
}
@ViewBuilder
private func channelRow(
title: String,
subtitlePrefix: String,
subtitleName: String? = nil,
subtitleNameBold: Bool = false,
isSelected: Bool,
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
@@ -304,17 +237,20 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary)
}
}
let subtitleFull: String = {
if let name = subtitleName, !name.isEmpty {
return subtitlePrefix + "" + name
HStack(spacing: 0) {
Text(subtitlePrefix)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
}
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
if isSelected {
@@ -322,11 +258,11 @@ struct LocationChannelsSheet: View {
.font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen)
}
trailingAccessory()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: action)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// 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 {
// 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 noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
}
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false }
-87
View File
@@ -1,87 +0,0 @@
import SwiftUI
/// Mining body animation: scramble characters (matrix-like) until PoW completes.
struct MiningScrambleText: View {
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(display)
.font(.system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced))
.foregroundColor(color)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear {
timer?.invalidate(); timer = nil
}
}
/// Combined prefix+body single-Text variant to ensure identical wrapping
struct MiningCombinedText: View {
let prefix: AttributedString
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(combinedAttributed)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear { timer?.invalidate(); timer = nil }
}
private var combinedAttributed: AttributedString {
var res = AttributedString()
res.append(prefix)
var style = AttributeContainer()
style.foregroundColor = color
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
res.append(AttributedString(display).mergingAttributes(style))
return res
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
@@ -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"))
}
}
+13 -13
View File
@@ -2,17 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
</plist>
-32
View File
@@ -1,32 +0,0 @@
import XCTest
import CryptoKit
@testable import bitchat
final class NostrPoWTests: XCTestCase {
func testLeadingZeroBits() {
// 0x00 -> 8, 0x00 -> 8, 0xF0 -> 0 leading zeros
let data = Data([0x00, 0x00, 0xF0]) + Data(repeating: 0x00, count: 29)
let lz = NostrPoW.leadingZeroBits(data)
XCTAssertEqual(lz, 16)
}
func testMineLowDifficulty() {
let pubkey = String(repeating: "a", count: 64)
let createdAt = Int(Date().timeIntervalSince1970)
let kind = 20000
let tags = [["g", "u4pruydqqvj"]]
let content = "hello"
let targetBits = 8 // keep it very low for test speed
let (nonce, idHex) = NostrPoW.mine(pubkey: pubkey, createdAt: createdAt, kind: kind, baseTags: tags, content: content, targetBits: targetBits)
XCTAssertGreaterThan(nonce, 0)
// Verify difficulty
let powTags = tags + [["nonce", String(nonce), String(targetBits)]]
// Recompute ID
let serialized: [Any] = [0, pubkey, createdAt, kind, powTags, content]
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let digest = CryptoKit.SHA256.hash(data: json)
let idData = Data(digest)
XCTAssertEqual(idHex.count, 64)
XCTAssertGreaterThanOrEqual(NostrPoW.leadingZeroBits(idData), targetBits)
}
}