Compare commits

...
Author SHA1 Message Date
97fc21c23f Add localizable strings in show commands view (#828)
* Add localizable strings in show commands view n refactor code to remove duplicate string declarations.

* Modify struct to enum in separate file to Models/CommandsInfo.

* Add corrections code in refactory and enum.

* Correct code in command enum and ContentView.

* Dedicated CommandSuggestionsView to extract code

* Simplify CommandInfo + minor optimization

* Fix preview

---------

Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-29 22:09:43 +00:00
Jithin RenjiandGitHub 79bd4af912 Remove redundant conditional compilation directive (#863) 2025-10-29 16:02:34 +00:00
96c78ef5a2 Fix verification persistence on iOS when app backgrounds (#822)
* Fix verification persistence on iOS when app backgrounds (#785)

Verification status was not being saved when the iOS app entered background
state, causing verified contacts to lose their verification status after the
app was closed. This happened because iOS can terminate backgrounded apps
without calling applicationWillTerminate.

Changes:
- Add saveIdentityState() method to force-save identity data without stopping services
- Call saveIdentityState() when iOS app enters background state
- Refactor applicationWillTerminate() to use new method
- Fix selector name typo (appWillTerminate -> applicationWillTerminate)

The verification data is now properly persisted to encrypted keychain storage
whenever the app backgrounds, ensuring verification status persists across
app launches.

Fixes #785

* Save identity state during verification events

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 19:19:00 +01:00
01256f3041 Add source-based routing support (#862)
* Add source-based routing support

* include neighbors in ANNOUNCE

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-10-28 12:38:44 +01:00
2edbe2bbbd Extract public message pipeline from ChatViewModel (#870)
* Extract public timeline helpers and rate limiter

* Extract public message pipeline

* Fix Swift concurrency warnings

* Incorporate public chat review feedback

* Tighten nostr key removal loop

* Address review feedback

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-28 12:10:09 +01:00
jackandGitHub a91978c10e Extract public timeline helpers and rate limiter (#869) 2025-10-27 14:13:57 +01:00
20 changed files with 1558 additions and 770 deletions
+6 -4
View File
@@ -57,11 +57,9 @@ struct BitchatApp: App {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
appDelegate.chatViewModel = chatViewModel
#endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content
@@ -189,6 +187,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func applicationWillTerminate(_ application: UIApplication) {
chatViewModel?.applicationWillTerminate()
}
}
#endif
+6 -2
View File
@@ -21,8 +21,9 @@ struct BitchatPacket: Codable {
let payload: Data
var signature: Data?
var ttl: UInt8
var route: [Data]?
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
self.version = version
self.type = type
self.senderID = senderID
@@ -31,6 +32,7 @@ struct BitchatPacket: Codable {
self.payload = payload
self.signature = signature
self.ttl = ttl
self.route = route
}
// Convenience initializer for new binary format
@@ -53,6 +55,7 @@ struct BitchatPacket: Codable {
self.payload = payload
self.signature = nil
self.ttl = ttl
self.route = nil
}
var data: Data? {
@@ -81,7 +84,8 @@ struct BitchatPacket: Codable {
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version
version: version,
route: route
)
return BinaryProtocol.encode(unsignedPacket)
}
+58
View File
@@ -0,0 +1,58 @@
//
// CommandsInfo.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
case block
case clear
case hug
case message = "dm"
case slap
case unblock
case who
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands
}
}
+14
View File
@@ -136,6 +136,20 @@ extension PeerID {
}
}
extension PeerID {
var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
let short = toShort()
return Data(hexString: short.id)
}
init?(routingData: Data) {
guard routingData.count == 8 else { return nil }
self.init(hexData: routingData)
}
}
// MARK: - Validation
extension PeerID {
+51 -12
View File
@@ -137,6 +137,7 @@ struct BinaryProtocol {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
}
// Encode BitchatPacket to binary format
@@ -160,8 +161,21 @@ struct BinaryProtocol {
}
let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
if hop.count > senderIDSize { return Data(hop.prefix(senderIDSize)) }
var padded = hop
padded.append(Data(repeating: 0, count: senderIDSize - hop.count))
return padded
}
guard sanitizedRoute.count <= 255 else { return nil }
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = payload.count + originalSizeFieldBytes
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
@@ -185,6 +199,7 @@ struct BinaryProtocol {
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
if hasRoute { flags |= Flags.hasRoute }
data.append(flags)
if version == 2 {
@@ -212,6 +227,13 @@ struct BinaryProtocol {
}
}
if hasRoute {
data.append(UInt8(sanitizedRoute.count))
for hop in sanitizedRoute {
data.append(hop)
}
}
if isCompressed, let originalSize = originalPayloadSize {
if version == 2 {
let value = UInt32(originalSize)
@@ -321,9 +343,27 @@ struct BinaryProtocol {
if recipientID == nil { return nil }
}
var route: [Data]? = nil
var remainingPayloadBytes = payloadLength
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
hops.append(hop)
}
route = hops
}
}
let payload: Data
if isCompressed {
guard payloadLength >= lengthFieldBytes else { return nil }
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
@@ -332,16 +372,12 @@ struct BinaryProtocol {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
// Guard to keep decompression bounded to sane BLE payload limits
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
remainingPayloadBytes -= lengthFieldBytes
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = payloadLength - lengthFieldBytes
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
let compressedSize = remainingPayloadBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
// Validate compression ratio to prevent zip bomb attacks
// Primary protection: originalSize capped at 1MB (line 336)
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
guard compressedSize > 0 else { return nil }
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
@@ -352,7 +388,9 @@ struct BinaryProtocol {
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let rawPayload = readData(payloadLength) else { return nil }
guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
payload = rawPayload
}
@@ -372,7 +410,8 @@ struct BinaryProtocol {
payload: payload,
signature: signature,
ttl: ttl,
version: version
version: version,
route: route
)
}
}
+14
View File
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
case .location(let ch): return ch.geohash
}
}
var isMesh: Bool {
switch self {
case .mesh: true
case .location: false
}
}
var isLocation: Bool {
switch self {
case .mesh: false
case .location: true
}
}
}
+26 -1
View File
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
let nickname: String
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
case signingPublicKey = 0x03
case directNeighbors = 0x04
}
func encode() -> Data? {
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
// TLV for direct neighbors (optional)
if let neighbors = directNeighbors, !neighbors.isEmpty {
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
data.append(TLVType.directNeighbors.rawValue)
data.append(UInt8(neighborsData.count))
data.append(neighborsData)
}
}
return data
}
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
var nickname: String?
var noisePublicKey: Data?
var signingPublicKey: Data?
var directNeighbors: [Data]?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
noisePublicKey = Data(value)
case .signingPublicKey:
signingPublicKey = Data(value)
case .directNeighbors:
if length > 0 && length % 8 == 0 {
var neighbors = [Data]()
let count = length / 8
for i in 0..<count {
let start = value.startIndex + i * 8
let end = start + 8
neighbors.append(Data(value[start..<end]))
}
directNeighbors = neighbors
}
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
return AnnouncementPacket(
nickname: nickname,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors
)
}
}
+100 -10
View File
@@ -75,6 +75,7 @@ final class BLEService: NSObject {
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter
}()
private let meshTopology = MeshTopologyTracker()
// 5. Fragment Reassembly (necessary for messages > MTU)
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
@@ -569,6 +570,7 @@ final class BLEService: NSObject {
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
meshTopology.reset()
}
// MARK: Connectivity and peers
@@ -726,6 +728,8 @@ final class BLEService: NSObject {
version: 2
)
self.applyRouteIfAvailable(&packet, to: peerID)
if let signed = self.noiseService.signPacket(packet) {
packet = signed
}
@@ -745,7 +749,7 @@ final class BLEService: NSObject {
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -754,6 +758,7 @@ final class BLEService: NSObject {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send read receipt: \(error)")
@@ -1226,7 +1231,7 @@ final class BLEService: NSObject {
if noiseService.hasEstablishedSession(with: peerID) {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -1235,6 +1240,7 @@ final class BLEService: NSObject {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send delivery ACK: \(error)")
@@ -1407,10 +1413,15 @@ final class BLEService: NSObject {
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let connectedPeerIDs: [Data] = collectionsQueue.sync {
peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData }
}
let announcement = AnnouncementPacket(
nickname: myNickname,
noisePublicKey: noisePub,
signingPublicKey: signingPub
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs
)
guard let payload = announcement.encode() else {
@@ -1739,6 +1750,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
peers[peerID] = info
}
}
clearDirectLink(with: peerID)
}
// Restart scanning with allow duplicates for faster rediscovery
@@ -2026,6 +2038,7 @@ extension BLEService: CBPeripheralDelegate {
peripherals[peripheralUUID] = state
}
peerToPeripheralUUID[senderID] = peripheralUUID
registerDirectLink(with: senderID)
}
let msgID = makeMessageID(for: packet)
@@ -2192,6 +2205,7 @@ extension BLEService: CBPeripheralManagerDelegate {
// Clean up mappings
centralToPeerID.removeValue(forKey: centralUUID)
clearDirectLink(with: peerID)
// Update UI immediately
notifyUI { [weak self] in
@@ -2308,7 +2322,10 @@ extension BLEService: CBPeripheralManagerDelegate {
subscribedCentrals.append(sorted[0].central)
}
if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
if packet.ttl == messageTTL {
centralToPeerID[centralUUID] = senderID
registerDirectLink(with: senderID)
}
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
@@ -2418,6 +2435,63 @@ extension BLEService {
)
}
private func routingData(for peerID: PeerID) -> Data? {
peerID.toShort().routingData
}
private func registerDirectLink(with peerID: PeerID) {
meshTopology.recordDirectLink(between: myPeerIDData, and: routingData(for: peerID))
}
private func clearDirectLink(with peerID: PeerID) {
meshTopology.removeDirectLink(between: myPeerIDData, and: routingData(for: peerID))
}
private func registerRoute(_ route: [Data]?) {
guard let hops = route, !hops.isEmpty else { return }
meshTopology.recordRoute(hops)
}
private func computeRoute(to peerID: PeerID) -> [Data]? {
meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID))
}
private func applyRouteIfAvailable(_ packet: inout BitchatPacket, to recipient: PeerID) {
guard let route = computeRoute(to: recipient), route.count >= 2 else { return }
packet.route = route
meshTopology.recordRoute(route)
}
private func routingPeer(from data: Data) -> PeerID? {
PeerID(routingData: data)
}
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
guard let route = packet.route, !route.isEmpty else { return false }
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
guard let selfData = myRoutingData,
let index = route.firstIndex(of: selfData) else { return false }
// No further hops: respect explicit route termination
if index == route.count - 1 {
return true
}
guard packet.ttl > 1 else { return true }
let nextHopData = route[index + 1]
guard let nextPeer = routingPeer(from: nextHopData),
isPeerConnected(nextPeer) else {
return false
}
registerDirectLink(with: nextPeer)
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
sendPacketDirected(relayPacket, to: nextPeer)
return true
}
/// Safely fetch the current direct-link state for a peer using the BLE queue.
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
let computeState = { () -> (Bool, Bool) in
@@ -2451,6 +2525,7 @@ extension BLEService {
let fingerprint = noiseService.getIdentityFingerprint()
myPeerID = PeerID(str: fingerprint.prefix(16))
myPeerIDData = Data(hexString: myPeerID.id) ?? Data()
meshTopology.reset()
}
private func restartGossipManager() {
@@ -2469,7 +2544,7 @@ extension BLEService {
}
do {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -2478,6 +2553,7 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to send verification payload: \(error)")
@@ -2696,7 +2772,7 @@ extension BLEService {
}
}
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: recipientData,
@@ -2705,6 +2781,7 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: recipientID)
broadcastPacket(packet)
@@ -2744,7 +2821,7 @@ extension BLEService {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
// Send handshake init
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -2753,6 +2830,7 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
broadcastPacket(packet)
} catch {
SecureLogger.error("Failed to initiate handshake: \(error)")
@@ -2786,7 +2864,7 @@ extension BLEService {
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
let packet = BitchatPacket(
var packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -2795,6 +2873,7 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&packet, to: peerID)
// We're already on messageQueue from the callback
broadcastPacket(packet)
@@ -2946,7 +3025,8 @@ extension BLEService {
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl
ttl: packet.ttl,
route: packet.route
)
let workItem = DispatchWorkItem { [weak self] in
@@ -3161,6 +3241,11 @@ extension BLEService {
}
return
}
registerRoute(packet.route)
if peerID != myPeerID && packet.ttl == messageTTL {
registerDirectLink(with: peerID)
}
// Deduplication (thread-safe)
let senderID = PeerID(hexData: packet.senderID)
@@ -3245,6 +3330,10 @@ extension BLEService {
break
}
if forwardAlongRouteIfNeeded(packet) {
return
}
// Relay if TTL > 1 and we're not the original sender
// Relay decision and scheduling (extracted via RelayController)
do {
@@ -3591,7 +3680,7 @@ extension BLEService {
do {
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
// Send response
let responsePacket = BitchatPacket(
var responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -3600,6 +3689,7 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
applyRouteIfAvailable(&responsePacket, to: peerID)
// We're on messageQueue from delegate callback
broadcastPacket(responsePacket)
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
/// Tracks observed mesh topology and computes hop-by-hop routes.
final class MeshTopologyTracker {
private typealias RoutingID = Data
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
func reset() {
queue.sync(flags: .barrier) {
self.adjacency.removeAll()
}
}
func recordDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
func removeDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
if var setA = self.adjacency[left] {
setA.remove(right)
self.adjacency[left] = setA.isEmpty ? nil : setA
}
if var setB = self.adjacency[right] {
setB.remove(left)
self.adjacency[right] = setB.isEmpty ? nil : setB
}
}
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
for neighbor in neighbors {
if var set = self.adjacency[neighbor] {
set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
}
}
}
}
func recordRoute(_ hops: [Data]) {
let sanitized = hops.compactMap { sanitize($0) }
guard sanitized.count >= 2 else { return }
queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) {
let left = sanitized[idx]
let right = sanitized[idx + 1]
guard left != right else { continue }
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [source] }
let graph = queue.sync { adjacency }
guard graph[source] != nil, graph[target] != nil else { return nil }
var visited: Set<RoutingID> = [source]
var queuePaths: [[RoutingID]] = [[source]]
var index = 0
while index < queuePaths.count {
let path = queuePaths[index]
index += 1
guard path.count <= maxHops else { continue }
guard let last = path.last, let neighbors = graph[last] else { continue }
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
var nextPath = path
nextPath.append(neighbor)
if neighbor == target { return nextPath }
if nextPath.count <= maxHops {
queuePaths.append(nextPath)
}
visited.insert(neighbor)
}
}
return nil
}
// MARK: - Helpers
private func sanitize(_ data: Data?) -> Data? {
guard var value = data, !value.isEmpty else { return nil }
if value.count > hopSize {
value = Data(value.prefix(hopSize))
} else if value.count < hopSize {
value.append(Data(repeating: 0, count: hopSize - value.count))
}
return value
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
//
// GeoChannelCoordinator.swift
// bitchat
//
// Centralizes Combine wiring for location channel selection and sampling.
//
import Combine
import Foundation
import Tor
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
private var bookmarkedGeohashes: [String] = []
init(
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
start()
}
func start() {
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
bookmarkedGeohashes = bookmarksStore.bookmarks
locationManager.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
}
}
.store(in: &cancellables)
locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self else { return }
self.regionalGeohashes = channels.map { $0.geohash }
self.updateSampling()
}
.store(in: &cancellables)
bookmarksStore.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self else { return }
self.bookmarkedGeohashes = bookmarks
self.updateSampling()
}
.store(in: &cancellables)
locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self, state == .authorized else { return }
Task { @MainActor [weak self] in
self?.locationManager.refreshChannels()
}
}
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
}
updateSampling()
}
private func updateSampling() {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
} else {
endSampling()
}
}
}
func refreshSampling() {
updateSampling()
}
private static func defaultLocationManager() -> LocationChannelManager {
LocationChannelManager.shared
}
@MainActor
private static func defaultTorManager() -> TorManager {
TorManager.shared
}
}
@@ -0,0 +1,77 @@
//
// MessageRateLimiter.swift
// bitchat
//
// Handles per-sender and per-content token buckets for public message intake.
//
import Foundation
struct MessageRateLimiter {
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var senderBuckets: [String: TokenBucket] = [:]
private var contentBuckets: [String: TokenBucket] = [:]
private let senderCapacity: Double
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
}
@@ -0,0 +1,210 @@
//
// MinimalDistancePalette.swift
// bitchat
//
// Lightweight palette generator that keeps peer colors evenly spaced.
//
import Foundation
import SwiftUI
final class MinimalDistancePalette {
struct Config {
let slotCount: Int
let avoidCenterHue: Double
let avoidHueDelta: Double
let saturationLight: Double
let saturationDark: Double
let baseBrightnessLight: Double
let baseBrightnessDark: Double
let ringBrightnessDeltaLight: Double
let ringBrightnessDeltaDark: Double
let preferredBiasWeight: Double
let goldenStep: Int
init(
slotCount: Int,
avoidCenterHue: Double,
avoidHueDelta: Double,
saturationLight: Double,
saturationDark: Double,
baseBrightnessLight: Double,
baseBrightnessDark: Double,
ringBrightnessDeltaLight: Double,
ringBrightnessDeltaDark: Double,
preferredBiasWeight: Double = 0.05,
goldenStep: Int = 7
) {
self.slotCount = slotCount
self.avoidCenterHue = avoidCenterHue
self.avoidHueDelta = avoidHueDelta
self.saturationLight = saturationLight
self.saturationDark = saturationDark
self.baseBrightnessLight = baseBrightnessLight
self.baseBrightnessDark = baseBrightnessDark
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
self.preferredBiasWeight = preferredBiasWeight
self.goldenStep = goldenStep
}
}
private struct Entry {
let slot: Int
let ring: Int
let hue: Double
}
private let config: Config
private var currentSeeds: [String: String] = [:]
private var entries: [String: Entry] = [:]
private var previousEntries: [String: Entry] = [:]
init(config: Config) {
self.config = config
}
@MainActor
func ensurePalette(for seeds: [String: String]) {
guard seeds != currentSeeds || entries.count != seeds.count else { return }
previousEntries = entries
currentSeeds = seeds
rebuildEntries()
}
@MainActor
func color(for identifier: String, isDark: Bool) -> Color? {
guard let entry = entries[identifier] else { return nil }
let saturation = isDark ? config.saturationDark : config.saturationLight
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
}
@MainActor
func reset() {
currentSeeds.removeAll()
entries.removeAll()
previousEntries.removeAll()
}
@MainActor
private func rebuildEntries() {
guard !currentSeeds.isEmpty else {
entries.removeAll()
return
}
let slotCount = max(8, config.slotCount)
var slots: [Double] = []
for idx in 0..<slotCount {
let hue = Double(idx) / Double(slotCount)
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
continue
}
slots.append(hue)
}
if slots.isEmpty {
for idx in 0..<slotCount {
slots.append(Double(idx) / Double(slotCount))
}
}
func circularDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b)
return diff > 0.5 ? 1.0 - diff : diff
}
let peerIDs = currentSeeds.keys.sorted()
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
let seed = currentSeeds[id] ?? id
let hash = seed.djb2()
let index = Int(hash % UInt64(slots.count))
return (id, index)
})
var mapping: [String: Entry] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prior = entries.isEmpty ? previousEntries : entries
for (id, entry) in prior {
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
let hue = slots[entry.slot]
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
usedSlots.insert(entry.slot)
usedHues.append(hue)
}
let unassigned = peerIDs.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = preferredIndex[id] ?? 0
if !usedSlots.contains(preferred), preferred < slots.count {
let hue = slots[preferred]
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
usedSlots.insert(preferred)
usedHues.append(hue)
continue
}
var bestSlot: Int?
var bestScore = -Double.infinity
for slot in 0..<slots.count where !usedSlots.contains(slot) {
let hue = slots[slot]
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDistance + config.preferredBiasWeight * bias
if score > bestScore {
bestScore = score
bestSlot = slot
}
}
if let slot = bestSlot {
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
usedSlots.insert(slot)
usedHues.append(hue)
}
}
let remaining = peerIDs.filter { mapping[$0] == nil }
if !remaining.isEmpty {
for (index, id) in remaining.enumerated() {
let preferred = preferredIndex[id] ?? 0
let slot = (preferred + index * config.goldenStep) % slots.count
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
}
}
entries = mapping
}
}
extension MinimalDistancePalette.Config {
static let mesh = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
static let nostr = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
}
@@ -0,0 +1,190 @@
//
// PublicMessagePipeline.swift
// bitchat
//
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
//
import Foundation
@MainActor
protocol PublicMessagePipelineDelegate: AnyObject {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
}
@MainActor
final class PublicMessagePipeline {
weak var delegate: PublicMessagePipelineDelegate?
private var buffer: [BitchatMessage] = []
private var timer: Timer?
private let baseFlushInterval: TimeInterval
private var dynamicFlushInterval: TimeInterval
private var recentBatchSizes: [Int] = []
private let maxRecentBatchSamples: Int
private let dedupWindow: TimeInterval
private var activeChannel: ChannelID = .mesh
init(
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
maxRecentBatchSamples: Int = 10,
dedupWindow: TimeInterval = 1.0
) {
self.baseFlushInterval = baseFlushInterval
self.dynamicFlushInterval = baseFlushInterval
self.maxRecentBatchSamples = maxRecentBatchSamples
self.dedupWindow = dedupWindow
}
deinit {
timer?.invalidate()
}
func updateActiveChannel(_ channel: ChannelID) {
activeChannel = channel
}
func enqueue(_ message: BitchatMessage) {
buffer.append(message)
scheduleFlush()
}
func flushIfNeeded() {
flushBuffer()
}
func reset() {
timer?.invalidate()
timer = nil
buffer.removeAll(keepingCapacity: false)
}
}
private extension PublicMessagePipeline {
func scheduleFlush() {
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: dynamicFlushInterval, repeats: false) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.flushBuffer()
}
}
}
func flushBuffer() {
timer?.invalidate()
timer = nil
guard !buffer.isEmpty else { return }
guard let delegate = delegate else {
buffer.removeAll(keepingCapacity: false)
return
}
delegate.pipelineSetBatchingState(self, isBatching: true)
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
var pending: [(message: BitchatMessage, contentKey: String)] = []
var batchContentLatest: [String: Date] = [:]
for message in buffer {
if existingIDs.contains(message.id) { continue }
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
if let ts = batchContentLatest[contentKey],
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
continue
}
existingIDs.insert(message.id)
pending.append((message, contentKey))
batchContentLatest[contentKey] = message.timestamp
}
buffer.removeAll(keepingCapacity: true)
guard !pending.isEmpty else {
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty { scheduleFlush() }
return
}
pending.sort { $0.message.timestamp < $1.message.timestamp }
var messages = delegate.pipelineCurrentMessages(self)
let threshold = lateInsertThreshold(for: activeChannel)
let lastTimestamp = messages.last?.timestamp ?? .distantPast
for item in pending {
let message = item.message
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
let index = insertionIndex(for: message.timestamp, in: messages)
if index >= messages.count {
messages.append(message)
} else {
messages.insert(message, at: index)
}
} else {
messages.append(message)
}
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
}
delegate.pipeline(self, setMessages: messages)
delegate.pipelineTrimMessages(self)
updateFlushInterval(withBatchSize: pending.count)
for item in pending {
delegate.pipelinePrewarmMessage(self, message: item.message)
}
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty {
scheduleFlush()
}
}
func updateFlushInterval(withBatchSize size: Int) {
recentBatchSizes.append(size)
if recentBatchSizes.count > maxRecentBatchSamples {
recentBatchSizes.removeFirst(recentBatchSizes.count - maxRecentBatchSamples)
}
let avg = recentBatchSizes.isEmpty
? 0.0
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
}
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
switch channel {
case .mesh:
return TransportConfig.uiLateInsertThreshold
case .location:
return TransportConfig.uiLateInsertThresholdGeo
}
}
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
}
@@ -0,0 +1,124 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
}
}
@@ -0,0 +1,90 @@
//
// CommandSuggestionsView.swift
// bitchat
//
// Created by Islam on 29/10/2025.
//
import SwiftUI
struct CommandSuggestionsView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Binding var messageText: String
let textColor: Color
let backgroundColor: Color
let secondaryTextColor: Color
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationManager.selectedChannel.isLocation
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
command.alias.starts(with: messageText.lowercased())
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
}
private func buttonRow(for command: CommandInfo) -> some View {
HStack {
Text(command.alias)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
if let placeholder = command.placeholder {
Text(placeholder)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Spacer()
Text(command.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@available(iOS 17, macOS 14, *)
#Preview {
@Previewable @State var messageText: String = "/"
let keychain = KeychainManager()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
CommandSuggestionsView(
messageText: $messageText,
textColor: .green,
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(viewModel)
}
+7 -131
View File
@@ -43,8 +43,6 @@ struct ContentView: View {
@State private var showPeerList = false
@State private var showSidebar = false
@State private var showAppInfo = false
@State private var showCommandSuggestions = false
@State private var commandSuggestions: [String] = []
@State private var showMessageActions = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@@ -605,77 +603,12 @@ struct ContentView: View {
.padding(.horizontal, 12)
}
// Command suggestions
if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
// Define commands with aliases and syntax
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/block"], "[nickname]", "block or list blocked peers"),
(["/clear"], nil, "clear chat messages"),
(["/hug"], "<nickname>", "send someone a warm hug"),
(["/m", "/msg"], "<nickname> [message]", "send private message"),
(["/slap"], "<nickname>", "slap someone with a trout"),
(["/unblock"], "<nickname>", "unblock a peer"),
(["/w"], nil, "see who's online")
]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites")
]
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
// Build the display
let allCommands = commandInfo
// Show matching commands
ForEach(commandSuggestions, id: \.self) { command in
// Find the command info for this suggestion
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
Button(action: {
// Replace current text with selected command
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
}) {
HStack {
// Show all aliases together
Text(info.commands.joined(separator: ", "))
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
// Show syntax if any
if let syntax = info.syntax {
Text(syntax)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Spacer()
// Show description
Text(info.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
}
CommandSuggestionsView(
messageText: $messageText,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor
)
// Recording indicator
if isPreparingVoiceNote || isRecordingVoiceNote {
@@ -709,68 +642,11 @@ struct ContentView: View {
)
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate()
// Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
let isGeoPublic: Bool = {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
var commandDescriptions = [
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
}
let input = newValue.lowercased()
// Map of aliases to primary commands
let aliases: [String: String] = [
"/join": "/j",
"/msg": "/m"
]
// Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
// Also check if input matches an alias
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary)
}
}
}
// Remove duplicates and sort
commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
commandSuggestions = []
}
}
HStack(alignment: .center, spacing: 4) {
@@ -787,7 +663,7 @@ struct ContentView: View {
.padding(.bottom, 8)
.background(backgroundColor.opacity(0.95))
}
private func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
@@ -54,6 +54,86 @@ struct BinaryProtocolTests {
#expect(decodedPacket.signature != nil)
#expect(decodedPacket.signature == TestConstants.testSignature)
}
@Test func packetWithRouteRoundTrip() throws {
let route: [Data] = [
try #require(Data(hexString: "0102030405060708")),
try #require(Data(hexString: "1112131415161718")),
try #require(Data(hexString: "2122232425262728"))
]
var packet = BitchatPacket(
type: 0x01,
senderID: route[0],
recipientID: route.last,
timestamp: 1_720_000_000_000,
payload: Data("route-test".utf8),
signature: nil,
ttl: 6
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route")
let flagsByte = encoded[BinaryProtocol.Offsets.flags]
#expect((flagsByte & BinaryProtocol.Flags.hasRoute) != 0)
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route")
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute.count == route.count)
for (expected, actual) in zip(route, decodedRoute) {
#expect(actual == expected)
}
}
@Test func packetWithRoutePadsShortHop() throws {
let sender = try #require(Data(hexString: "0011223344556677"))
let destination = try #require(Data(hexString: "8899aabbccddeeff"))
let shortHop = Data([0xAA, 0xBB, 0xCC])
var packet = BitchatPacket(
type: 0x02,
senderID: sender,
recipientID: destination,
timestamp: 1_730_000_000_000,
payload: Data("pad-test".utf8),
signature: nil,
ttl: 5
)
packet.route = [shortHop, destination]
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with short hop route")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with short hop route")
let decodedRoute = try #require(decoded.route)
let firstHop = try #require(decodedRoute.first)
#expect(firstHop.count == BinaryProtocol.senderIDSize)
#expect(firstHop.prefix(shortHop.count) == shortHop)
let paddingBytes = firstHop.suffix(firstHop.count - shortHop.count)
#expect(paddingBytes.allSatisfy { $0 == 0 })
}
@Test func packetWithRouteAndCompressedPayload() throws {
let route: [Data] = [
try #require(Data(hexString: "0101010101010101")),
try #require(Data(hexString: "0202020202020202"))
]
let repeatedString = String(repeating: "compress-me", count: 150)
var packet = BitchatPacket(
type: 0x03,
senderID: route[0],
recipientID: route.last,
timestamp: 1_740_000_000_000,
payload: Data(repeatedString.utf8),
signature: nil,
ttl: 7
)
packet.route = route
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with route and compression")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with route and compression")
#expect(decoded.payload == Data(repeatedString.utf8))
let decodedRoute = try #require(decoded.route)
#expect(decodedRoute == route)
}
// MARK: - Compression Tests
@@ -0,0 +1,86 @@
//
// MeshTopologyTrackerTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
struct MeshTopologyTrackerTests {
private func hex(_ value: String) throws -> Data {
try #require(Data(hexString: value))
}
@Test func directLinkProducesRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0102030405060708")
let b = try hex("1112131415161718")
tracker.recordDirectLink(between: a, and: b)
let route = try #require(tracker.computeRoute(from: a, to: b))
#expect(route == [a, b])
}
@Test func multiHopRouteComputation() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0001020304050607")
let b = try hex("1011121314151617")
let c = try hex("2021222324252627")
let d = try hex("3031323334353637")
tracker.recordDirectLink(between: a, and: b)
tracker.recordDirectLink(between: b, and: c)
tracker.recordDirectLink(between: c, and: d)
let route = try #require(tracker.computeRoute(from: a, to: d))
#expect(route == [a, b, c, d])
}
@Test func recordRouteAddsEdges() throws {
let tracker = MeshTopologyTracker()
var a = Data([0xAA, 0xBB, 0xCC])
let b = try hex("4445464748494A4B")
let c = try hex("5455565758595A5B")
tracker.recordRoute([a, b, c])
a.append(Data(repeating: 0, count: BinaryProtocol.senderIDSize - a.count))
let route = try #require(tracker.computeRoute(from: a, to: c))
#expect(route.first == a)
#expect(route.last == c)
}
@Test func removingDirectLinkBreaksRoute() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0101010101010101")
let b = try hex("0202020202020202")
let c = try hex("0303030303030303")
tracker.recordDirectLink(between: a, and: b)
tracker.recordDirectLink(between: b, and: c)
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
#expect(initialRoute == [a, b, c])
tracker.removeDirectLink(between: b, and: c)
#expect(tracker.computeRoute(from: a, to: c) == nil)
}
@Test func removingPeerClearsEdges() throws {
let tracker = MeshTopologyTracker()
let a = try hex("0F0E0D0C0B0A0908")
let b = try hex("0A0B0C0D0E0F0001")
let c = try hex("0011223344556677")
tracker.recordRoute([a, b, c])
let initialRoute = try #require(tracker.computeRoute(from: a, to: c))
#expect(initialRoute == [a, b, c])
tracker.removePeer(b)
#expect(tracker.computeRoute(from: a, to: c) == nil)
}
}
+21 -1
View File
@@ -217,7 +217,27 @@ struct PeerIDTests {
let short = peerID.toShort()
#expect(short == peerID)
}
@Test func routingData_fromShortID() throws {
let peerID = PeerID(str: hex16)
let routing = try #require(peerID.routingData)
#expect(routing.count == 8)
#expect(routing == Data(hexString: hex16))
}
@Test func routingData_fromNoiseKey() throws {
let peerID = PeerID(str: hex64)
let routing = try #require(peerID.routingData)
let expectedShort = peerID.toShort()
#expect(routing == Data(hexString: expectedShort.id))
}
@Test func routingPeerRoundTrip() throws {
let raw = try #require(Data(hexString: hex16))
let peerID = try #require(PeerID(routingData: raw))
#expect(peerID.routingData == raw)
}
// MARK: - Codable
@Test func codable_emptyPrefix() throws {