Compare commits

..
Author SHA1 Message Date
jack 0bc5cbe0b7 QR verification: speed + persistence + UX
- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping
2025-08-24 23:11:20 +02:00
jack 77f0fa46c7 QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder) 2025-08-24 18:26:19 +02:00
jack 81fa77b761 QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist 2025-08-24 18:23:37 +02:00
jack 0c7054ce30 QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage 2025-08-24 18:16:42 +02:00
jack 921b9f1be6 QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now 2025-08-24 18:13:55 +02:00
jack e86dbc8d38 QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly 2025-08-24 18:09:24 +02:00
jack 652deab8a5 QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header 2025-08-24 18:06:22 +02:00
22 changed files with 847 additions and 578 deletions
-4
View File
@@ -71,7 +71,3 @@ __pycache__/
# Local build results
.Result*/
.Result*.xcresult/
TestResult.xcresult/
*.xcresult/
build.log
*.log
-5
View File
@@ -17,7 +17,6 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; };
@@ -246,7 +245,6 @@
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -443,7 +441,6 @@
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
@@ -758,7 +755,6 @@
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
@@ -816,7 +812,6 @@
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
+13 -1
View File
@@ -109,8 +109,20 @@ struct BitchatApp: App {
userDefaults.removeObject(forKey: "sharedContentDate")
userDefaults.synchronize()
// Send the shared content immediately on the main queue
// Show notification about shared content
DispatchQueue.main.async {
// Add system message about sharing
let systemMessage = BitchatMessage(
sender: "system",
content: "preparing to share \(contentType)...",
timestamp: Date(),
isRelay: false
)
self.chatViewModel.messages.append(systemMessage)
}
// Send the shared content after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if contentType == "url" {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
+51 -2
View File
@@ -173,7 +173,56 @@ struct PendingActions {
var setPetname: String?
}
//
// MARK: - Privacy Settings
struct PrivacySettings: Codable {
// Level 1: Maximum privacy (default)
var persistIdentityCache = false
var showLastSeen = false
// Level 2: Convenience
var autoAcceptKnownFingerprints = false
var rememberNicknameHistory = false
// Level 3: Social
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
}
// MARK: - Conflict Resolution
/// Strategies for resolving identity conflicts in the decentralized network.
/// Handles cases where multiple peers claim the same nickname or when
/// identity mappings become ambiguous due to network partitions.
enum ConflictResolution {
case acceptNew(petname: String) // "John (2)"
case rejectNew
case blockFingerprint(String)
case alertUser(message: String)
}
// MARK: - UI State
struct PeerUIState {
let peerID: String
let nickname: String
var identityState: IdentityState
var connectionQuality: ConnectionQuality
enum IdentityState {
case unknown // Gray - No identity info
case unverifiedKnown(String) // Blue - Handshake done, matches cache
case verified(String) // Green - Cryptographically verified
case conflict(String, String) // Red - Nickname doesn't match fingerprint
case pending // Yellow - Handshake in progress
}
}
enum ConnectionQuality {
case excellent
case good
case poor
case disconnected
}
// MARK: - Migration Support
//
// Removed LegacyFavorite - no longer needed
+216 -1
View File
@@ -89,4 +89,219 @@ struct BitchatPeer: Identifiable, Equatable {
}
}
//
// MARK: - Peer Manager
/// Manages the collection of peers and their states
@MainActor
class PeerManager: ObservableObject {
@Published var peers: [BitchatPeer] = []
@Published var favorites: [BitchatPeer] = []
@Published var mutualFavorites: [BitchatPeer] = []
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
init(meshService: Transport) {
self.meshService = meshService
updatePeers()
// Listen for updates
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteChanged),
name: .favoriteStatusChanged,
object: nil
)
}
@objc private func handleFavoriteChanged() {
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
category: SecureLogger.session, level: .debug)
updatePeers()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func updatePeers() {
// Reduce log verbosity - only log when count changes
let previousCount = peers.count
// Get current mesh peers
let meshPeers = meshService.getPeerNicknames()
// Build peer list
var allPeers: [BitchatPeer] = []
var connectedNicknames: Set<String> = []
var addedPeerIDs: Set<String> = []
// Add connected mesh peers (only if actually connected or relay connected)
for (peerID, nickname) in meshPeers {
guard let noiseKey = Data(hexString: peerID) else { continue }
// Safety check: Never add our own peer ID
if peerID == meshService.myPeerID {
continue
}
// Check if this peer is actually connected
let isConnected = meshService.isPeerConnected(peerID)
// Skip disconnected peers unless they're favorites (handled later)
if !isConnected {
continue
}
if isConnected {
connectedNicknames.insert(nickname)
}
// Track that we've added this peer ID
addedPeerIDs.insert(peerID)
var peer = BitchatPeer(
id: peerID,
noisePublicKey: noiseKey,
nickname: nickname,
isConnected: isConnected
)
// Set favorite status - check both by current noise key and by nickname
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
if let favorite = favoriteByNickname {
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
category: SecureLogger.session, level: .info)
// Update the favorite's noise key to match the current connection
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
// Get the updated favorite with the new key
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
allPeers.append(peer)
}
// Add offline favorites (only those not currently connected AND that we actively favorite)
for (favoriteKey, favorite) in favoritesService.favorites {
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
// Skip if this peer is already connected (by nickname)
if connectedNicknames.contains(favorite.peerNickname) {
// Skipping favorite - already connected
continue
}
// Skip if we already added a peer with this ID (prevents duplicates)
if addedPeerIDs.contains(favoriteID) {
// Skipping favorite - peer ID already added
continue
}
// Only add peers that WE favorite (not just ones who favorite us)
if !favorite.isFavorite {
// Skipping - we don't favorite them
continue
}
// Add this favorite as an offline peer
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
category: SecureLogger.session, level: .info)
var peer = BitchatPeer(
id: favoriteID,
noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname,
isConnected: false
)
// Set favorite status
peer.favoriteStatus = favorite
peer.nostrPublicKey = favorite.peerNostrPublicKey
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
allPeers.append(peer)
}
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
allPeers = allPeers.filter { peer in
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
}
// Sort: Connected first, then favorites, then alphabetical
allPeers.sort { lhs, rhs in
// Direct connections first
if lhs.isConnected != rhs.isConnected {
return lhs.isConnected
}
// Then favorites
if lhs.isFavorite != rhs.isFavorite {
return lhs.isFavorite
}
// Finally alphabetical
return lhs.displayName < rhs.displayName
}
// Single pass to compute all subsets and counts
var favorites: [BitchatPeer] = []
var mutualFavorites: [BitchatPeer] = []
var connectedCount = 0
var offlineCount = 0
for peer in allPeers {
if peer.isFavorite {
favorites.append(peer)
}
if peer.isMutualFavorite {
mutualFavorites.append(peer)
}
if peer.isConnected {
connectedCount += 1
} else {
offlineCount += 1
}
}
// Final safety check: ensure no duplicate IDs
var finalPeers: [BitchatPeer] = []
var seenIDs: Set<String> = []
for peer in allPeers {
if !seenIDs.contains(peer.id) {
seenIDs.insert(peer.id)
finalPeers.append(peer)
} else {
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
}
}
self.peers = finalPeers
self.favorites = favorites
self.mutualFavorites = mutualFavorites
// Log peer list summary sparingly at debug level
if favoritesService.favorites.count > 0 {
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
category: SecureLogger.session, level: .debug)
} else if previousCount != allPeers.count {
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
category: SecureLogger.session, level: .debug)
}
}
func toggleFavorite(_ peer: BitchatPeer) {
if peer.isFavorite {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
} else {
favoritesService.addFavorite(
peerNoisePublicKey: peer.noisePublicKey,
peerNostrPublicKey: peer.nostrPublicKey,
peerNickname: peer.nickname
)
}
updatePeers()
}
}
+15 -10
View File
@@ -122,8 +122,8 @@ struct NostrProtocol {
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
let signingKey = try senderIdentity.signingKey()
return try event.sign(with: signingKey)
}
// MARK: - Private Methods
@@ -149,8 +149,9 @@ struct NostrProtocol {
content: encrypted
)
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey)
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
return try seal.sign(with: signingKey)
}
private static func createGiftWrap(
@@ -180,8 +181,9 @@ struct NostrProtocol {
content: encrypted
)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
return try giftWrap.sign(with: signingKey)
}
private static func unwrapGiftWrap(
@@ -414,7 +416,7 @@ struct NostrProtocol {
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
// Removed unnecessary date formatting operations
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
@@ -470,16 +472,19 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId()
// Sign with Schnorr (BIP-340)
// Convert to Schnorr key for Nostr signing
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
+5 -3
View File
@@ -181,7 +181,8 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed
}
//
// MARK: - Special Recipients (removed)
// Previously defined broadcast identifiers were unused; removed for simplicity.
// MARK: - Core Protocol Structures
@@ -267,7 +268,8 @@ struct BitchatPacket: Codable {
}
}
//
// MARK: - Delivery Acknowledgments (removed)
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
// MARK: - Read Receipts
@@ -359,7 +361,7 @@ struct ReadReceipt: Codable {
}
//
// PeerIdentityBinding removed (unused).
// MARK: - Delivery Status
+3 -3
View File
@@ -14,7 +14,7 @@ class AutocompleteService {
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear",
"/msg", "/who", "/clear", "/help",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
@@ -95,10 +95,10 @@ class AutocompleteService {
private func needsArgument(command: String) -> Bool {
switch command {
case "/who", "/clear":
case "/who", "/clear", "/help":
return false
default:
return true
}
}
}
}
+9 -196
View File
@@ -112,16 +112,6 @@ final class BLEService: NSObject {
private var scheduledRelays: [String: DispatchWorkItem] = [:]
// Track short-lived traffic bursts to adapt announces/scanning under load
private var recentPacketTimestamps: [Date] = []
// Ingress link tracking for last-hop suppression
private enum LinkID: Hashable {
case peripheral(String)
case central(String)
}
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
// Backpressure-aware write queue per peripheral
private var pendingPeripheralWrites: [String: [Data]] = [:]
// MARK: - Maintenance Timer
@@ -166,88 +156,6 @@ final class BLEService: NSObject {
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
}
}
// MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
}
private func subsetSizeForFanout(_ n: Int) -> Int {
guard n > 0 else { return 0 }
if n <= 2 { return n }
// approx ceil(log2(n)) + 1 without floating point
var v = n - 1
var bits = 0
while v > 0 { v >>= 1; bits += 1 }
return min(n, max(1, bits + 1))
}
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
// Stable order by SHA256(seed || "::" || id)
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: msg))
scored.append((digest, id))
}
scored.sort { a, b in
for i in 0..<min(a.score.count, b.score.count) {
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
}
return a.id < b.id
}
return Set(scored.prefix(k).map { $0.id })
}
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
// BLE operations run on bleQueue; keep queue affinity
bleQueue.async { [weak self] in
guard let self = self else { return }
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data)
}
}
}
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
var queueCopy: [Data] = []
self.collectionsQueue.sync {
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
}
guard !queueCopy.isEmpty else { return }
var sent = 0
for item in queueCopy {
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(item, for: ch, type: .withoutResponse)
sent += 1
} else {
break
}
}
if sent > 0 {
self.collectionsQueue.async(flags: .barrier) {
var q = self.pendingPeripheralWrites[uuid] ?? []
if sent <= q.count {
q.removeFirst(sent)
} else {
q.removeAll()
}
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
}
}
}
}
// MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
@@ -461,7 +369,7 @@ final class BLEService: NSObject {
// Send to peripherals we're connected to as central
for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
}
@@ -730,7 +638,7 @@ final class BLEService: NSObject {
}
}
//
// Removed unused getPeers(): use getPeerNicknames() from Transport
// MARK: - Private Message Handling
@@ -969,7 +877,7 @@ final class BLEService: NSObject {
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
state.isConnected,
let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
sentEncrypted = true
}
@@ -1000,10 +908,6 @@ final class BLEService: NSObject {
}
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
// Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let states = snapshotPeripheralStates()
var minCentralWriteLen: Int?
for s in states where s.isConnected {
@@ -1028,54 +932,15 @@ final class BLEService: NSObject {
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return
}
// Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link
let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }
let subscribedCentrals: [CBCentral]
var centralIDs: [String] = []
if let _ = characteristic {
let (centrals, _) = snapshotSubscribedCentrals()
subscribedCentrals = centrals
centralIDs = centrals.map { $0.identifier.uuidString }
} else {
subscribedCentrals = []
}
// Exclude ingress link
var allowedPeripheralIDs = connectedPeripheralIDs
var allowedCentralIDs = centralIDs
if let ingress = ingressLink {
switch ingress {
case .peripheral(let id):
allowedPeripheralIDs.removeAll { $0 == id }
case .central(let id):
allowedCentralIDs.removeAll { $0 == id }
}
}
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
}
// Writes to selected connected peripherals
// Writes to connected peripherals
for s in states where s.isConnected {
let pid = s.peripheral.identifier.uuidString
guard selectedPeripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch)
s.peripheral.writeValue(data, for: ch, type: .withoutResponse)
}
}
// Notify selected subscribed centrals
// Notify all subscribed centrals
if let ch = characteristic {
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
}
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
}
}
@@ -1089,7 +954,7 @@ final class BLEService: NSObject {
// Fire-and-forget principle: always use .withoutResponse for speed
// CoreBluetooth will handle fragmentation at L2CAP layer
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
// MARK: - Fragmentation (Required for messages > BLE MTU)
@@ -1297,7 +1162,6 @@ final class BLEService: NSObject {
ttl: packet.ttl,
senderIsSelf: senderID == myPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
degree: degree,
@@ -1863,15 +1727,6 @@ final class BLEService: NSObject {
}
}
}
// Clean ingress link records older than 3 seconds
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let cutoff = now.addingTimeInterval(-3)
if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
}
}
private func updateScanningDutyCycle(connectedCount: Int) {
@@ -2262,27 +2117,6 @@ extension BLEService {
// Test-only helper to inject packets into the receive pipeline
extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
// Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = packet.senderID.hexEncodedString()
collectionsQueue.sync(flags: .barrier) {
if peers[normalizedID] == nil {
peers[normalizedID] = PeerInfo(
id: normalizedID,
nickname: "TestPeer_\(fromPeerID.prefix(4))",
isConnected: true,
noisePublicKey: packet.senderID,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date()
)
} else {
var p = peers[normalizedID]!
p.isConnected = true
p.isVerifiedNickname = true
p.lastSeen = Date()
peers[normalizedID] = p
}
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
handleReceivedPacket(packet, from: fromPeerID)
} else {
@@ -2411,22 +2245,12 @@ extension BLEService: CBPeripheralDelegate {
peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer
}
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
// Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID)
} else {
// For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers
// Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
handleReceivedPacket(packet, from: senderID)
}
}
@@ -2441,8 +2265,7 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
// Resume queued writes for this peripheral
drainPendingWrites(for: peripheral)
// Suppress verbose ready logs
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
@@ -2666,18 +2489,8 @@ extension BLEService: CBPeripheralManagerDelegate {
}
if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID)
} else {
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID)
}
} else {
+11 -34
View File
@@ -33,15 +33,6 @@ class CommandProcessor {
guard let cmd = parts.first else { return .error(message: "Invalid command") }
let args = parts.count > 1 ? String(parts[1]) : ""
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
}()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
switch cmd {
case "/m", "/msg":
return handleMessage(args)
@@ -58,14 +49,11 @@ class CommandProcessor {
case "/unblock":
return handleUnblock(args)
case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true)
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
return handleHelp()
default:
return .error(message: "unknown command: \(cmd)")
}
@@ -97,34 +85,19 @@ class CommandProcessor {
}
private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers
switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
let names = people.map { $0.displayName }
if names.isEmpty { return .success(message: "no one else is online right now") }
return .success(message: "online: " + names.sorted().joined(separator: ", "))
case .mesh:
// Mesh context: show connected peer nicknames
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
}
private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll()
} else {
chatViewModel?.clearCurrentPublicTimeline()
chatViewModel?.messages.removeAll()
}
return .handled
}
@@ -192,8 +165,12 @@ class CommandProcessor {
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = chatViewModel {
#if os(iOS)
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
#else
let visibleIndex: [String: String] = [:]
#endif
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
geoNames.append(name)
@@ -1,8 +1,8 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
#if os(iOS)
import CoreLocation
import Combine
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
@@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
if #available(iOS 14.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
@@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
// MARK: - Public API
func enableLocationChannels() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
if #available(iOS 14.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
@@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
Task { @MainActor in self.permissionState = .restricted }
case .denied:
Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse, .authorized:
case .authorizedAlways, .authorizedWhenInUse:
Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation()
@unknown default:
@@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
}
}
// iOS 14+ / macOS 11+
@available(iOS 14.0, macOS 11.0, *)
// iOS 14+
@available(iOS 14.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
@@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted
case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
@unknown default: newState = .restricted
}
Task { @MainActor in self.permissionState = newState }
@@ -256,4 +256,5 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
return dict
}
}
#endif
+9 -22
View File
@@ -12,7 +12,6 @@ struct RelayController {
static func decide(ttl: UInt8,
senderIsSelf: Bool,
isEncrypted: Bool,
isDirectedEncrypted: Bool,
isDirectedFragment: Bool,
isHandshake: Bool,
degree: Int,
@@ -20,17 +19,7 @@ struct RelayController {
// Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
// For session-critical or directed traffic, be deterministic and reliable
if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types
let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
// Degree-aware probability to reduce floods in dense graphs
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
@@ -39,22 +28,20 @@ struct RelayController {
case 7...9: baseProb = 0.55
default: baseProb = 0.45
}
let prob = baseProb
var prob = baseProb
if isHandshake { prob = max(0.3, baseProb - 0.2) }
// Sample a forwarding decision
let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping in dense graphs (only for broadcast)
// TTL clamping in dense graphs
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often
let delayMs: Int
switch degree {
case 0...2: delayMs = Int.random(in: 40...100)
case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
// Short jitter to desynchronize rebroadcasts
let delayMs = Int.random(in: 20...80)
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
}
}
+4 -8
View File
@@ -18,18 +18,14 @@ struct InputValidator {
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (exact 16-hex)
// Accept short routing IDs (16-hex)
if PeerIDResolver.isShortID(peerID) { return true }
// If length equals short-hex length but isn't valid hex, reject
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
// Accept full Noise key hex (64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// If length equals full key length but isn't valid hex, reject
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
// Internal format: alphanumeric + dash/underscore up to 64
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty &&
peerID.count < Limits.maxPeerIDLength &&
peerID.count <= Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
}
+115 -40
View File
@@ -297,7 +297,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
//
// Missing properties that were removed during refactoring
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
private var selectedPrivateChatFingerprint: String? = nil
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
@@ -352,12 +352,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private let maxProcessedNostrEvents = 2000
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
// Location channel state
#if os(iOS)
@Published private var activeChannel: ChannelID = .mesh
private var geoSubscriptionID: String? = nil
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
#endif
// MARK: - Caches
@@ -387,6 +389,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Persist mesh public timeline across channel switches
private var meshTimeline: [BitchatMessage] = []
private let meshTimelineCap = 1337
#if os(iOS)
// Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = 1337
@@ -402,6 +405,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
#endif
// MARK: - Message Delivery Tracking
@@ -588,7 +592,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.cancellables.insert(cancellable)
// Resubscribe geohash on relay reconnect
// Resubscribe geohash on relay reconnect (iOS only)
#if os(iOS)
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
@@ -602,11 +607,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
.store(in: &self.cancellables)
}
#endif
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
#if os(iOS)
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
@@ -621,6 +628,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
#endif
// Request notification permission
NotificationService.shared.requestAuthorization()
@@ -706,7 +714,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Force immediate save
userDefaults.synchronize()
}
#if os(iOS)
// Resubscribe to the active geohash channel without clearing timeline
@MainActor
private func resubscribeCurrentGeohash() {
@@ -888,6 +897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
} catch { }
}
#endif
// MARK: - Nickname Management
@@ -1215,6 +1225,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentions = parseMentions(from: content)
// Add message to local display
#if os(iOS)
var displaySender = nickname
var localSenderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
@@ -1223,6 +1234,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
displaySender = nickname + "#" + suffix
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))"
}
#else
let displaySender = nickname
let localSenderPeerID = meshService.myPeerID
#endif
let message = BitchatMessage(
sender: displaySender,
@@ -1242,6 +1257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let ckey = normalizedContentKey(message.content)
recordContentKey(ckey, timestamp: message.timestamp)
// Persist to channel-specific timelines
#if os(iOS)
switch activeChannel {
case .mesh:
meshTimeline.append(message)
@@ -1252,19 +1268,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[ch.geohash] = arr
}
#else
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
#endif
trimMessagesIfNeeded()
// Force immediate UI update for user's own messages
objectWillChange.send()
// Update channel activity time on send
#if os(iOS)
switch activeChannel {
case .mesh:
lastPublicActivityAt["mesh"] = Date()
case .location(let ch):
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
}
#endif
#if os(iOS)
if case .location(let ch) = activeChannel {
// Send to geohash channel via Nostr ephemeral
Task { @MainActor in
@@ -1303,10 +1326,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions)
}
#else
// Send via mesh with mentions (non-iOS)
meshService.sendMessage(content, mentions: mentions)
#endif
}
}
#if os(iOS)
@MainActor
private func switchLocationChannel(to channel: ChannelID) {
// Flush pending public buffer to avoid cross-channel bleed
@@ -1349,12 +1376,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// 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)
#if os(iOS)
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)
}
#endif
}
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
@@ -1561,10 +1590,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} catch {
// ignore
}
//
// Presence announcement removed; we will tag actual chat events instead
}
// MARK: - Geohash Participants
// MARK: - Geohash Participants (iOS)
#if os(iOS)
struct GeoPerson: Identifiable, Equatable {
let id: String // pubkey hex (lowercased)
let displayName: String
@@ -1622,8 +1652,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
geoParticipantsTimer?.invalidate()
geoParticipantsTimer = nil
}
// MARK: - Public helpers
#endif
// MARK: - Public helpers (iOS)
#if os(iOS)
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
@MainActor
func visibleGeohashPeople() -> [GeoPerson] {
@@ -1714,7 +1746,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
//
// Unsubscribe removed
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID)
@@ -1741,6 +1773,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) }
geoSamplingSubs.removeAll()
}
#endif
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
@@ -1760,12 +1793,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Helper: display name for current active channel (for notifications)
private func activeChannelDisplayName() -> String {
#if os(iOS)
switch activeChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
}
#else
return "#mesh"
#endif
}
// Dedup helper with small memory cap
@@ -1782,6 +1819,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
}
#endif
/// Sends an encrypted private message to a specific peer.
/// - Parameters:
@@ -1793,6 +1831,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard !content.isEmpty else { return }
// Geohash DM routing: conversation keys start with "nostr_"
#if os(iOS)
if peerID.hasPrefix("nostr_") {
guard case .location(let ch) = activeChannel else {
addSystemMessage("cannot send: not in a location channel")
@@ -1858,6 +1897,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return
}
#endif
// Check if blocked
if unifiedPeerService.isBlocked(peerID) {
@@ -1924,6 +1964,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
#if os(iOS)
// MARK: - Geohash DMs initiation
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
@@ -1949,6 +1990,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return "anon#\(suffix)"
}
#endif
/// Add a local system message to a private chat (no network send)
@MainActor
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
@@ -2402,7 +2444,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Also resubscribe the current geohash channel if active
#if os(iOS)
resubscribeCurrentGeohash()
#endif
}
@MainActor
@@ -2447,6 +2491,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
// In public chat - send to active public channel
#if os(iOS)
switch activeChannel {
case .mesh:
meshService.sendMessage(screenshotMessage, mentions: [])
@@ -2480,7 +2525,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
}
#else
meshService.sendMessage(screenshotMessage, mentions: [])
#endif
// Show local notification immediately as system message
let localNotification = BitchatMessage(
@@ -2560,6 +2607,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChatManager.markAsRead(from: peerID)
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
#if os(iOS)
if peerID.hasPrefix("nostr_"),
let recipientHex = nostrKeyMapping[peerID],
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
@@ -2577,6 +2625,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return
}
#endif
// Get the peer's Noise key to check for Nostr messages
var noiseKeyHex: String? = nil
@@ -2673,6 +2722,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func getPeerIDForNickname(_ nickname: String) -> String? {
#if os(iOS)
// When in a geohash channel, allow resolving by geohash participant nickname
switch LocationChannelManager.shared.selectedChannel {
case .location:
@@ -2686,10 +2736,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
nostrKeyMapping[convKey] = pub
return convKey
}
default:
break
default: break
}
// Fallback to mesh nickname resolution
#endif
return unifiedPeerService.getPeerID(for: nickname)
}
@@ -2797,6 +2846,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func updateAutocomplete(for text: String, cursorPosition: Int) {
// Build candidate list based on active channel
let peerCandidates: [String] = {
#if os(iOS)
switch activeChannel {
case .mesh:
let values = meshService.getPeerNicknames().values
@@ -2815,6 +2865,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return Array(tokens)
}
#else
let values = meshService.getPeerNicknames().values
return Array(values.filter { $0 != meshService.myNickname })
#endif
}()
let (suggestions, range) = autocompleteService.getSuggestions(
@@ -2935,12 +2989,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Determine if this message was sent by self (mesh, geo, or DM)
let isSelf: Bool = {
if let spid = message.senderPeerID {
// In geohash channels, compare against our per-geohash nostr short ID
#if os(iOS)
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))"
}
}
#endif
return spid == meshService.myPeerID
}
// Fallback by nickname
@@ -3091,9 +3146,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
// Determine if this mention targets me (resolves with optional suffix per active channel)
let mySuffix: String? = {
#if os(iOS)
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return String(id.publicKeyHex.suffix(4))
}
#endif
return String(meshService.myPeerID.prefix(4))
}()
let isMentionToMe: Bool = {
@@ -3126,28 +3183,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let token = String(matchText.dropFirst()).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
// Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash)
let attachedToMention: Bool = {
// nsRange is the Range<String.Index> for this match within content
// Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention
if nsRange.lowerBound > content.startIndex {
var i = content.index(before: nsRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
}
return false
}()
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url
tagStyle.underlineStyle = .single
}
@@ -3545,6 +3586,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Clear the current public channel's timeline (visible + persistent buffer)
@MainActor
func clearCurrentPublicTimeline() {
#if os(iOS)
switch activeChannel {
case .mesh:
messages.removeAll()
@@ -3553,6 +3595,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messages.removeAll()
geoTimelines[ch.geohash] = []
}
#else
messages.removeAll()
meshTimeline.removeAll()
#endif
}
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
@@ -3617,8 +3663,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return unifiedPeerService.getFingerprint(for: peerID)
}
//
private func getFingerprint_old(for peerID: String) -> String? {
// Remove debug logging to prevent console spam during view updates
// First try to get fingerprint from mesh service's peer ID rotation mapping
if let fingerprint = meshService.getFingerprint(for: peerID) {
return fingerprint
}
// Check noise service (direct Noise session fingerprint)
if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) {
return fingerprint
}
// Last resort: check local mapping
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
return fingerprint
}
return nil
}
// Helper to resolve nickname for a peer ID through various sources
@MainActor
@@ -4016,7 +4080,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messageRouter.flushOutbox(for: peerID)
}
//
// Connection messages removed to reduce chat noise
}
func didDisconnectFromPeer(_ peerID: String) {
@@ -4083,7 +4147,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
//
// Disconnection messages removed to reduce chat noise
}
func didUpdatePeerList(_ peers: [String]) {
@@ -4367,6 +4431,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Used for emotes where we want a local system-style confirmation instead.
@MainActor
func sendPublicRaw(_ content: String) {
#if os(iOS)
if case .location(let ch) = activeChannel {
Task { @MainActor in
do {
@@ -4390,13 +4455,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
return
}
#endif
// Default: send over mesh
meshService.sendMessage(content, mentions: [])
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
//
// Removed inlined Nostr send helpers in favor of MessageRouter
@MainActor
private func setupNostrMessageHandling() {
@@ -4727,7 +4793,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return Data(base64Encoded: str)
}
//
// Removed local TLV decoder; using PrivateMessagePacket.decode from Protocols
@MainActor
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
@@ -4992,10 +5058,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Geohash Nickname Resolution (for /block in geohash)
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
// Look up current visible geohash participants for an exact displayName match
// Look up current visible geohash participants for an exact displayName match (iOS only)
#if os(iOS)
for p in visibleGeohashPeople() {
if p.displayName == name { return p.id }
}
#endif
return nil
}
@@ -5297,7 +5365,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
trimMeshTimelineIfNeeded()
}
// Persist geochat messages to per-geohash timeline
// Persist geochat messages to per-geohash timeline (iOS-only)
#if os(iOS)
if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? []
@@ -5306,14 +5375,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
geoTimelines[gh] = arr
}
}
#endif
// Only add message to current timeline if it matches active channel or is system
let isSystem = finalMessage.sender == "system"
let channelMatches: Bool = {
#if os(iOS)
switch activeChannel {
case .mesh: return !isGeo || isSystem
case .location: return isGeo || isSystem
}
#else
// On non-iOS builds, we don't have location channels; accept all
return true
#endif
}()
guard channelMatches else { return }
+173 -59
View File
@@ -13,9 +13,19 @@ import UIKit
// MARK: - Supporting Types
//
// Pre-computed peer data for performance
struct PeerDisplayData: Identifiable {
let id: String
let displayName: String
let isFavorite: Bool
let isMe: Bool
let hasUnreadMessages: Bool
let encryptionStatus: EncryptionStatus
let connectionState: BitchatPeer.ConnectionState
let isMutualFavorite: Bool
}
//
// (Link previews removed; URLs are now clickable inline)
// MARK: - Main Content View
@@ -23,7 +33,9 @@ struct ContentView: View {
// MARK: - Properties
@EnvironmentObject var viewModel: ChatViewModel
#if os(iOS)
@ObservedObject private var locationManager = LocationChannelManager.shared
#endif
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -188,6 +200,7 @@ struct ContentView: View {
Button("direct message") {
if let peerID = selectedMessageSenderID {
#if os(iOS)
if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
@@ -195,6 +208,9 @@ struct ContentView: View {
} else {
viewModel.startPrivateChat(with: peerID)
}
#else
viewModel.startPrivateChat(with: peerID)
#endif
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
@@ -216,6 +232,7 @@ struct ContentView: View {
Button("BLOCK", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
#if os(iOS)
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
@@ -223,6 +240,9 @@ struct ContentView: View {
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
}
Button("cancel", role: .cancel) {}
@@ -270,6 +290,7 @@ struct ContentView: View {
let windowedMessages = messages.suffix(currentWindowCount)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
@@ -277,6 +298,12 @@ struct ContentView: View {
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(items, id: \.uiID) { item in
@@ -372,6 +399,7 @@ struct ContentView: View {
// Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = 200
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
@@ -379,6 +407,12 @@ struct ContentView: View {
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? 300
@@ -447,6 +481,7 @@ struct ContentView: View {
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
#if os(iOS)
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
@@ -461,6 +496,7 @@ struct ContentView: View {
let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true)
LocationChannelManager.shared.select(ChannelID.location(ch))
#endif
}
.onTapGesture(count: 3) {
// Triple-tap to clear current chat
@@ -473,12 +509,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
@@ -493,12 +533,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
@@ -512,12 +556,16 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
@@ -543,12 +591,16 @@ struct ContentView: View {
if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed
lastScrollTime = now
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
@@ -559,12 +611,16 @@ struct ContentView: View {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
@@ -611,6 +667,7 @@ struct ContentView: View {
}
}
}
#if os(iOS)
.onChange(of: locationManager.selectedChannel) { newChannel in
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
@@ -629,6 +686,7 @@ struct ContentView: View {
}
}
}
#endif
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
@@ -698,22 +756,18 @@ struct ContentView: View {
if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
// Define commands with aliases and syntax
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/block"], "[nickname]", "block or list blocked peers"),
(["/clear"], nil, "clear chat messages"),
(["/fav"], "<nickname>", "add to favorites"),
(["/help"], nil, "show this help"),
(["/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"),
(["/unfav"], "<nickname>", "remove from favorites"),
(["/w"], nil, "see who's online")
]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == 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
@@ -788,25 +842,18 @@ struct ContentView: View {
// 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: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [
let commandDescriptions = [
("/block", "block or list blocked peers"),
("/clear", "clear chat messages"),
("/fav", "add to favorites"),
("/help", "show this help"),
("/hug", "send someone a warm hug"),
("/m", "send private message"),
("/slap", "slap someone with a trout"),
("/unblock", "unblock a peer"),
("/unfav", "remove from favorites"),
("/w", "see who's online")
]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
}
let input = newValue.lowercased()
@@ -888,7 +935,8 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
// Show QR in mesh on all platforms
// Show QR only on mesh channel's peer list
#if os(iOS)
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
@@ -897,6 +945,14 @@ struct ContentView: View {
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
}
#else
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
#endif
}
.frame(height: 44) // Match header height
.padding(.horizontal, 12)
@@ -909,34 +965,53 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 6) {
// People section
VStack(alignment: .leading, spacing: 4) {
if case .location = locationManager.selectedChannel {
GeohashPeopleList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
})
} else {
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
}
#if os(iOS)
if case .location = locationManager.selectedChannel {
GeohashPeopleList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
})
} else {
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
}
#else
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
#endif
}
}
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
@@ -1026,11 +1101,13 @@ struct ContentView: View {
return (name, "")
}
// Compute channel-aware people count and color for toolbar (cross-platform)
#if os(iOS)
// Compute channel-aware people count and color for toolbar
private func channelPeopleCountAndColor() -> (Int, Color) {
switch locationManager.selectedChannel {
case .location:
let n = viewModel.geohashPeople.count
// Use standard green (dark: system green; light: custom darker green)
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (n, n > 0 ? standardGreen : Color.secondary)
case .mesh:
@@ -1040,11 +1117,13 @@ struct ContentView: View {
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
return (counts.others, color)
}
}
#endif
private var mainHeaderView: some View {
@@ -1091,6 +1170,7 @@ struct ContentView: View {
// Channel badge + dynamic spacing + people counter
// Precompute header count and color outside the ViewBuilder expressions
#if os(iOS)
let cc = channelPeopleCountAndColor()
let headerCountColor: Color = cc.1
let headerOtherPeersCount: Int = {
@@ -1099,11 +1179,24 @@ struct ContentView: View {
}
return cc.0
}()
#else
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let headerOtherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
HStack(spacing: 10) {
// Unread icon immediately to the left of the channel badge (independent from channel button)
// Unread indicator (now shown on iOS and macOS)
// Unread indicator
#if os(iOS)
if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
@@ -1138,6 +1231,7 @@ struct ContentView: View {
.accessibilityLabel("location channels")
}
.buttonStyle(.plain)
#endif
HStack(spacing: 4) {
// People icon with count
@@ -1165,9 +1259,11 @@ struct ContentView: View {
}
.frame(height: 44)
.padding(.horizontal, 12)
#if os(iOS)
.sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
}
#endif
.background(backgroundColor.opacity(0.95))
}
@@ -1203,11 +1299,13 @@ struct ContentView: View {
let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick: String = {
if privatePeerID.hasPrefix("nostr_") {
#if os(iOS)
// Build geohash DM header: "#<ghash>/@name#abcd"
if case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)"
}
#endif
}
return peer?.displayName ??
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
@@ -1372,7 +1470,23 @@ private struct PaymentChipView: View {
}
}
//
// Helper view for rendering message content (plain, no hashtag/mention formatting)
struct MessageContentView: View {
let message: BitchatMessage
let viewModel: ChatViewModel
let colorScheme: ColorScheme
let isMentioned: Bool
var body: some View {
Text(message.content)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
// MARK: - Helper Methods
// buildTextSegments removed: content is rendered plain.
}
// Delivery status indicator view
struct DeliveryStatusView: View {
+12
View File
@@ -1,5 +1,6 @@
import SwiftUI
#if os(iOS)
struct GeohashPeopleList: View {
@ObservedObject var viewModel: ChatViewModel
let textColor: Color
@@ -28,12 +29,16 @@ struct GeohashPeopleList: View {
let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id }
#if os(iOS)
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false
}
#else
let isTeleportedID: (String) -> Bool = { _ in false }
#endif
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) }
@@ -47,7 +52,11 @@ struct GeohashPeopleList: View {
let person = personByID[pid]!
HStack(spacing: 4) {
let isMe = (person.id == myHex)
#if os(iOS)
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
#else
let teleported = false
#endif
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor
@@ -118,8 +127,10 @@ struct GeohashPeopleList: View {
}
}
}
#endif
// Helper to split a trailing #abcd suffix
#if os(iOS)
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
@@ -131,3 +142,4 @@ private func splitSuffix(from name: String) -> (String, String) {
}
return (name, "")
}
#endif
+13 -39
View File
@@ -1,10 +1,7 @@
import SwiftUI
import CoreLocation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared
@@ -40,7 +37,11 @@ struct LocationChannelsSheet: View {
Text("location permission denied. enable in settings to use location channels.")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() }
Button("open settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.plain)
}
case LocationChannelManager.PermissionState.authorized:
@@ -53,7 +54,6 @@ struct LocationChannelsSheet: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
@@ -61,21 +61,8 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced))
}
}
#else
.toolbar {
ToolbarItem(placement: .automatic) {
Button("close") { isPresented = false }
.font(.system(size: 14, design: .monospaced))
}
}
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.onAppear {
// Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -141,12 +128,10 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.system(size: 14, design: .monospaced))
.keyboardType(.asciiCapable)
.onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -198,7 +183,9 @@ struct LocationChannelsSheet: View {
// Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: {
openSystemLocationSettings()
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}) {
Text("remove location access")
.font(.system(size: 12, design: .monospaced))
@@ -356,7 +343,7 @@ extension LocationChannelsSheet {
}()
let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) {
if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
@@ -379,7 +366,7 @@ extension LocationChannelsSheet {
private func bluetoothRangeString() -> String {
let usesMetric: Bool = {
if #available(iOS 16.0, macOS 13.0, *) {
if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
@@ -403,17 +390,4 @@ extension LocationChannelsSheet {
}
}
// MARK: - Open Settings helper
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
#endif
+1 -3
View File
@@ -10,9 +10,7 @@
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
</plist>
+130 -130
View File
@@ -7,160 +7,160 @@
//
import UIKit
import Social
import UniformTypeIdentifiers
/// Modern share extension using UIKit + UTTypes.
/// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController {
private let statusLabel: UILabel = {
let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false
l.font = .systemFont(ofSize: 15, weight: .semibold)
l.textAlignment = .center
l.numberOfLines = 0
l.textColor = .label
return l
}()
class ShareViewController: SLComposeServiceViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.addSubview(statusLabel)
NSLayoutConstraint.activate([
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
processShare()
// Set placeholder text
placeholder = "Share to bitchat..."
// Set character limit (optional)
charactersRemaining = 500
}
// MARK: - Processing
private func processShare() {
guard let ctx = self.extensionContext,
let item = ctx.inputItems.first as? NSExtensionItem else {
finishWithMessage("Nothing to share")
override func isContentValid() -> Bool {
// Validate that we have text content or attachments
if let text = contentText, !text.isEmpty {
return true
}
// Check if we have attachments
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments,
!attachments.isEmpty {
return true
}
return false
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Try content from attributed text first (Safari often passes URL here)
if let url = detectURL(in: item.attributedContentText?.string ?? "") {
saveAndFinish(url: url, title: item.attributedTitle?.string)
return
}
// Scan attachments for URL/text
let providers = item.attachments ?? []
if providers.isEmpty {
// Fallback: use attributed title as plain text
if let title = item.attributedTitle?.string, !title.isEmpty {
saveAndFinish(text: title)
} else {
finishWithMessage("No shareable content")
// Get the page title from the compose view or extension item
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string
var foundURL: URL? = nil
let group = DispatchGroup()
// IMPORTANT: Check if the NSExtensionItem itself has a URL
// Safari often provides the URL as an attributedString with a link
if let attributedText = extensionItem.attributedContentText {
let text = attributedText.string
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
if let firstMatch = matches?.first, let url = firstMatch.url {
foundURL = url
}
return
}
// Load URL or text asynchronously
loadFirstURL(from: providers) { [weak self] url in
guard let self = self else { return }
if let url = url {
self.saveAndFinish(url: url, title: item.attributedTitle?.string)
} else {
self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
// Only check attachments if we haven't found a URL yet
if foundURL == nil {
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() {
// Try multiple URL type identifiers that Safari might use
let urlTypes = [
UTType.url.identifier,
"public.url",
"public.file-url"
]
for urlType in urlTypes {
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
defer { group.leave() }
if let url = item as? URL {
foundURL = url
} else if let data = item as? Data,
let urlString = String(data: data, encoding: .utf8),
let url = URL(string: urlString) {
foundURL = url
} else if let string = item as? String,
let url = URL(string: string) {
foundURL = url
}
}
break // Found a URL type, no need to check other types
}
}
// Also check for plain text that might be a URL
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in
defer { group.leave() }
if let text = item as? String {
// Check if the text is actually a URL
if let url = URL(string: text),
(url.scheme == "http" || url.scheme == "https") {
foundURL = url
}
} else {
self.finishWithMessage("No shareable content")
}
}
}
}
}
private func detectURL(in text: String) -> URL? {
guard !text.isEmpty else { return nil }
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(location: 0, length: (text as NSString).length)
let match = detector?.matches(in: text, options: [], range: range).first
return match?.url
}
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
let grp = DispatchGroup()
var found: URL?
for p in providers where found == nil {
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let u = item as? URL { found = u; return }
if let s = item as? String, let u = URL(string: s) { found = u; return }
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
} // End of if foundURL == nil
// Process after all checks complete
group.notify(queue: .main) { [weak self] in
if let url = foundURL {
// We have a URL! Create the JSON data
let urlData: [String: String] = [
"url": url.absoluteString,
"title": pageTitle ?? url.host ?? "Shared Link"
]
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
let jsonString = String(data: jsonData, encoding: .utf8) {
self?.saveToSharedDefaults(content: jsonString, type: "url")
}
break
} else if let title = pageTitle, !title.isEmpty {
// No URL found, just share the text
self?.saveToSharedDefaults(content: title, type: "text")
}
}
grp.notify(queue: .main) { completion(found) }
}
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
let id = UTType.plainText.identifier
let grp = DispatchGroup()
var text: String?
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let s = item as? String { text = s }
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
}
break
}
grp.notify(queue: .main) { completion(text) }
}
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? "Shared Link"
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage("✓ Shared link to bitchat")
} else {
finishWithMessage("Failed to encode link")
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}
private func saveAndFinish(text: String) {
saveToSharedDefaults(content: text, type: "text")
finishWithMessage("✓ Shared text to bitchat")
override func configurationItems() -> [Any]! {
// No configuration items needed
return []
}
// MARK: - Helper Methods
private func saveToSharedDefaults(content: String, type: String) {
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
// Use app groups to share data between extension and main app
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
return
}
userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate")
userDefaults.synchronize()
// Force open the main app
self.openMainApp()
}
private func finishWithMessage(_ msg: String) {
statusLabel.text = msg
// Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
private func openMainApp() {
// Share extensions cannot directly open the containing app
// The app will check for shared content when it becomes active
// Show success feedback to user
DispatchQueue.main.async {
self.textView.text = "✓ Shared to bitchat"
self.textView.isEditable = false
}
}
}
}
-10
View File
@@ -21,10 +21,8 @@ final class NostrProtocolTests: XCTestCase {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
#if DEBUG
print("Sender pubkey: \(sender.publicKeyHex)")
print("Recipient pubkey: \(recipient.publicKeyHex)")
#endif
// Create a test message
let originalContent = "Hello from NIP-17 test!"
@@ -36,10 +34,8 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender
)
#if DEBUG
print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)")
#endif
// Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -56,9 +52,7 @@ final class NostrProtocolTests: XCTestCase {
let timeDiff = abs(messageDate.timeIntervalSinceNow)
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
#if DEBUG
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
#endif
}
func testGiftWrapUsesUniqueEphemeralKeys() throws {
@@ -81,10 +75,8 @@ final class NostrProtocolTests: XCTestCase {
// Gift wrap pubkeys should be different (unique ephemeral keys)
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
#if DEBUG
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
#endif
// Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
@@ -117,9 +109,7 @@ final class NostrProtocolTests: XCTestCase {
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)) { error in
#if DEBUG
print("Expected error when decrypting with wrong key: \(error)")
#endif
}
}
@@ -0,0 +1,45 @@
//
// LegacyTestProtocolTypes.swift
// bitchatTests
//
// Minimal legacy protocol types used only by tests to simulate old flows.
// These are not part of production code anymore.
import Foundation
struct ProtocolNack {
let originalPacketID: String
let nackID: String
let senderID: String
let receiverID: String
let packetType: UInt8
let reason: String
let errorCode: UInt8
enum ErrorCode: UInt8 {
case unknown = 0
case decryptionFailed = 2
}
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
self.originalPacketID = originalPacketID
self.nackID = UUID().uuidString
self.senderID = senderID
self.receiverID = receiverID
self.packetType = packetType
self.reason = reason
self.errorCode = errorCode.rawValue
}
func toBinaryData() -> Data {
// Tests don't parse the payload; return a compact encoding for completeness
var data = Data()
data.appendUUID(originalPacketID)
data.appendUUID(nackID)
data.append(UInt8(packetType))
data.append(UInt8(errorCode))
data.appendString(reason)
return data
}
}
+13
View File
@@ -0,0 +1,13 @@
Command line invocation:
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
Build settings from command line:
CODE_SIGNING_ALLOWED = NO
ONLY_ACTIVE_ARCH = YES
SDKROOT = iphonesimulator18.5
Resolve Package Graph
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
Package: swift-secp256k1
fatalError