mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Fix post-rebase compilation errors
- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files) - Add caching to NostrIdentityBridge.deriveIdentity() for performance - Remove duplicate NotificationStreamAssembler from BLEService.swift - Remove duplicate function declarations in BLEService.swift - Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift - Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping) - Update ContentView body to use main's simple VStack structure - Fix NostrIdentityBridge instance method calls - Remove privateChatView (replaced with sheet-based UI in main) Build and tests passing (137/139 tests pass).
This commit is contained in:
@@ -58,288 +58,3 @@ struct NostrIdentity: Codable {
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private static var deviceSeedCache: Data?
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private static var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private static let cacheLock = NSLock()
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
|
||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||
|
||||
@@ -8,9 +8,12 @@ final class NostrIdentityBridge {
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
@@ -106,6 +109,14 @@ final class NostrIdentityBridge {
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
@@ -125,11 +136,22 @@ final class NostrIdentityBridge {
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,128 +7,6 @@ import CryptoKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct NotificationStreamAssembler {
|
||||
private var buffer = Data()
|
||||
private var pendingFrameStartedAt: DispatchTime?
|
||||
private var pendingFrameExpectedLength: Int = 0
|
||||
|
||||
private mutating func resetState() {
|
||||
buffer.removeAll(keepingCapacity: false)
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
}
|
||||
|
||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||
guard !chunk.isEmpty else { return ([], [], false) }
|
||||
|
||||
buffer.append(chunk)
|
||||
|
||||
var frames: [Data] = []
|
||||
var dropped: [UInt8] = []
|
||||
var didReset = false
|
||||
let now = DispatchTime.now()
|
||||
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
||||
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
||||
|
||||
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
||||
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
||||
resetState()
|
||||
return ([], [], true)
|
||||
}
|
||||
|
||||
while buffer.count >= minimumFramePrefix {
|
||||
guard let version = buffer.first else { break }
|
||||
guard version == 1 || version == 2 else {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
|
||||
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
||||
dropped.append(buffer.removeFirst())
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
continue
|
||||
}
|
||||
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
||||
guard buffer.count >= framePrefix else { break }
|
||||
|
||||
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
||||
guard flagsIndex < buffer.endIndex else { break }
|
||||
let flags = buffer[flagsIndex]
|
||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
||||
|
||||
let lengthOffset = 12
|
||||
let payloadLength: Int
|
||||
if version == 2 {
|
||||
let lengthIndex = buffer.startIndex + lengthOffset
|
||||
payloadLength =
|
||||
(Int(buffer[lengthIndex]) << 24) |
|
||||
(Int(buffer[lengthIndex + 1]) << 16) |
|
||||
(Int(buffer[lengthIndex + 2]) << 8) |
|
||||
Int(buffer[lengthIndex + 3])
|
||||
} else {
|
||||
let lengthIndex = buffer.startIndex + lengthOffset
|
||||
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
||||
}
|
||||
|
||||
var frameLength = framePrefix + payloadLength
|
||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||
if isCompressed {
|
||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
||||
if payloadLength < rawLengthFieldBytes {
|
||||
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
break
|
||||
}
|
||||
|
||||
if buffer.count < frameLength {
|
||||
let remaining = frameLength - buffer.count
|
||||
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
||||
pendingFrameStartedAt = now
|
||||
pendingFrameExpectedLength = frameLength
|
||||
} else if let started = pendingFrameStartedAt {
|
||||
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
||||
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
||||
if elapsed >= threshold {
|
||||
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
||||
resetState()
|
||||
didReset = true
|
||||
} else {
|
||||
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
pendingFrameStartedAt = nil
|
||||
pendingFrameExpectedLength = 0
|
||||
|
||||
let frame = Data(buffer.prefix(frameLength))
|
||||
frames.append(frame)
|
||||
buffer.removeFirst(frameLength)
|
||||
}
|
||||
|
||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||
resetState()
|
||||
}
|
||||
|
||||
return (frames, dropped, didReset)
|
||||
}
|
||||
}
|
||||
|
||||
/// BLEService — Bluetooth Mesh Transport
|
||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||
@@ -728,7 +606,7 @@ final class BLEService: NSObject {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
sendPrivateMessage(content, to: peerID.id, messageID: messageID)
|
||||
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
@@ -786,54 +664,11 @@ final class BLEService: NSObject {
|
||||
packet = signed
|
||||
}
|
||||
|
||||
SecureLogger.debug("📁 Sending private file transfer to \(peerID.prefix(8))… bytes=\(payload.count)", category: .session)
|
||||
SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session)
|
||||
self.broadcastPacket(packet, transferId: transferId)
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
||||
// Ensure this runs on message queue to avoid main thread blocking
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard content.count <= self.maxMessageLength else {
|
||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
let finalMessageID = messageID ?? UUID().uuidString
|
||||
let _ = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
if let recipientID = recipientID {
|
||||
// Private message
|
||||
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
||||
} else {
|
||||
// Public broadcast
|
||||
// Create packet with explicit fields so we can sign it
|
||||
let basePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||||
return
|
||||
}
|
||||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||||
let senderHex = signedPacket.senderID.hexEncodedString()
|
||||
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
// Call synchronously since we're already on background queue
|
||||
self.broadcastPacket(signedPacket)
|
||||
// Track our own broadcast for sync
|
||||
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
// Create typed payload: [type byte] + [message ID]
|
||||
@@ -859,6 +694,12 @@ final class BLEService: NSObject {
|
||||
}
|
||||
} else {
|
||||
// Queue for after handshake and initiate if needed
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||
}
|
||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
||||
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +740,7 @@ final class BLEService: NSObject {
|
||||
|
||||
// Per-link limits for the specific peer
|
||||
var peripheralMaxLen: Int?
|
||||
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {
|
||||
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[PeerID(str: recipientPeerID)] : bleQueue.sync(execute: { peerToPeripheralUUID[PeerID(str: recipientPeerID)] }) {
|
||||
if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {
|
||||
peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
}
|
||||
@@ -907,7 +748,7 @@ final class BLEService: NSObject {
|
||||
var centralMaxLen: Int?
|
||||
do {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
|
||||
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == PeerID(str: recipientPeerID) }) {
|
||||
centralMaxLen = central.maximumUpdateValueLength
|
||||
}
|
||||
}
|
||||
@@ -925,7 +766,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// Direct write via peripheral link
|
||||
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
|
||||
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[PeerID(str: recipientPeerID)] : bleQueue.sync(execute: { peerToPeripheralUUID[PeerID(str: recipientPeerID)] }),
|
||||
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
|
||||
state.isConnected,
|
||||
let characteristic = state.characteristic {
|
||||
@@ -936,7 +777,7 @@ final class BLEService: NSObject {
|
||||
// Notify via central link (dual-role)
|
||||
if let characteristic = characteristic, !sentEncrypted {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
|
||||
for central in centrals where mapping[central.identifier.uuidString] == PeerID(str: recipientPeerID) {
|
||||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
|
||||
if success { sentEncrypted = true; break }
|
||||
enqueuePendingNotification(data: data, centrals: [central], context: "encrypted")
|
||||
@@ -1084,10 +925,10 @@ final class BLEService: NSObject {
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
|
||||
var byMsg = self.pendingDirectedRelays[PeerID(str: recipientPeerID)] ?? [:]
|
||||
if byMsg[msgID] == nil {
|
||||
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
|
||||
self.pendingDirectedRelays[recipientPeerID] = byMsg
|
||||
self.pendingDirectedRelays[PeerID(str: recipientPeerID)] = byMsg
|
||||
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
@@ -1101,7 +942,7 @@ final class BLEService: NSObject {
|
||||
for (recipient, dict) in pendingDirectedRelays {
|
||||
for (_, entry) in dict {
|
||||
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
|
||||
out.append((recipient, entry.packet))
|
||||
out.append((recipient.id, entry.packet))
|
||||
}
|
||||
}
|
||||
// Clear recipient bucket; items will be re-spooled if still no links
|
||||
@@ -1388,7 +1229,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// Update peer info without verbose logging - update the peer we received from, not the original sender
|
||||
updatePeerLastSeen(peerID)
|
||||
updatePeerLastSeen(PeerID(str: peerID))
|
||||
|
||||
// Track recent traffic timestamps for adaptive behavior
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
@@ -1416,19 +1257,19 @@ final class BLEService: NSObject {
|
||||
handleRequestSync(packet, from: senderID)
|
||||
|
||||
case .noiseHandshake:
|
||||
handleNoiseHandshake(packet, from: senderID)
|
||||
|
||||
handleNoiseHandshake(packet, from: PeerID(str: senderID))
|
||||
|
||||
case .noiseEncrypted:
|
||||
handleNoiseEncrypted(packet, from: senderID)
|
||||
|
||||
handleNoiseEncrypted(packet, from: PeerID(str: senderID))
|
||||
|
||||
case .fragment:
|
||||
handleFragment(packet, from: senderID)
|
||||
|
||||
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
handleLeave(packet, from: PeerID(str: senderID))
|
||||
|
||||
default:
|
||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||
@@ -1477,7 +1318,7 @@ final class BLEService: NSObject {
|
||||
|
||||
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID
|
||||
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
|
||||
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
|
||||
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey).id
|
||||
if derivedFromKey != peerID {
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: .security)
|
||||
|
||||
@@ -1491,7 +1332,7 @@ final class BLEService: NSObject {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingPeerForVerify = collectionsQueue.sync { peers[peerID] }
|
||||
let existingPeerForVerify = collectionsQueue.sync { peers[PeerID(str: peerID)] }
|
||||
var verifiedAnnounce = false
|
||||
if packet.signature != nil {
|
||||
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
||||
@@ -1510,18 +1351,18 @@ final class BLEService: NSObject {
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
// Check if we have an actual BLE connection to this peer
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
let peripheralUUID = peerToPeripheralUUID[PeerID(str: peerID)]
|
||||
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
|
||||
|
||||
// Check if this peer is subscribed to us as a central
|
||||
// Note: We can't identify which specific central is which peer without additional mapping
|
||||
let hasCentralSubscription = centralToPeerID.values.contains(peerID)
|
||||
|
||||
let hasCentralSubscription = centralToPeerID.values.contains(PeerID(str: peerID))
|
||||
|
||||
// Direct announces arrive with full TTL (no prior hop)
|
||||
let isDirectAnnounce = (packet.ttl == messageTTL)
|
||||
|
||||
|
||||
// Check if we already have this peer (might be reconnecting)
|
||||
let existingPeer = peers[peerID]
|
||||
let existingPeer = peers[PeerID(str: peerID)]
|
||||
let wasDisconnected = existingPeer?.isConnected == false
|
||||
|
||||
// Set flags for use outside the sync block
|
||||
@@ -1540,8 +1381,8 @@ final class BLEService: NSObject {
|
||||
// Update or create peer info
|
||||
if let existing = existingPeer, existing.isConnected {
|
||||
// Update lastSeen and identity info
|
||||
peers[peerID] = PeerInfo(
|
||||
id: existing.id,
|
||||
peers[PeerID(str: peerID)] = PeerInfo(
|
||||
peerID: existing.peerID,
|
||||
nickname: announcement.nickname,
|
||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
@@ -1551,8 +1392,8 @@ final class BLEService: NSObject {
|
||||
)
|
||||
} else {
|
||||
// New peer or reconnecting peer
|
||||
peers[peerID] = PeerInfo(
|
||||
id: peerID,
|
||||
peers[PeerID(str: peerID)] = PeerInfo(
|
||||
peerID: PeerID(str: peerID),
|
||||
nickname: announcement.nickname,
|
||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
@@ -1569,11 +1410,11 @@ final class BLEService: NSObject {
|
||||
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
||||
} else if wasDisconnected {
|
||||
// Debounce 'reconnected' logs within short window
|
||||
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
|
||||
if let last = lastReconnectLogAt[PeerID(str: peerID)], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
|
||||
// Skip duplicate log
|
||||
} else {
|
||||
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
||||
lastReconnectLogAt[peerID] = now
|
||||
lastReconnectLogAt[PeerID(str: peerID)] = now
|
||||
}
|
||||
} else if existingPeer?.nickname != announcement.nickname {
|
||||
SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session)
|
||||
@@ -1595,35 +1436,26 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// Record this announce for lightweight rebroadcast buffer (exclude self)
|
||||
if peerID != myPeerID {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
||||
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
|
||||
=======
|
||||
}
|
||||
// (recentAnnounces has been removed in the refactor)
|
||||
|
||||
// Notify UI on main thread
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
|
||||
// Get current peer list (after addition)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
|
||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||
self.delegate?.didConnectToPeer(peerID)
|
||||
self.delegate?.didConnectToPeer(PeerID(str: peerID))
|
||||
// Schedule initial unicast sync to this peer
|
||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(PeerID(str: peerID), delaySeconds: 1.0)
|
||||
}
|
||||
|
||||
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
|
||||
|
||||
// Track for sync (include our own and others' announces)
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
|
||||
@@ -1633,7 +1465,7 @@ final class BLEService: NSObject {
|
||||
if shouldSendBack {
|
||||
messageDeduplicator.markProcessed(announceBackID)
|
||||
}
|
||||
|
||||
|
||||
if shouldSendBack {
|
||||
// Reciprocate announce for bidirectional discovery
|
||||
// Force send to ensure the peer receives our announce
|
||||
@@ -1655,7 +1487,7 @@ final class BLEService: NSObject {
|
||||
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
|
||||
return
|
||||
}
|
||||
gossipSyncManager?.handleRequestSync(fromPeerID: peerID, request: req)
|
||||
gossipSyncManager?.handleRequestSync(from: PeerID(str: peerID), request: req)
|
||||
}
|
||||
|
||||
// Mention parsing moved to ChatViewModel
|
||||
@@ -1677,12 +1509,12 @@ final class BLEService: NSObject {
|
||||
accepted = true
|
||||
senderNickname = myNickname
|
||||
}
|
||||
else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
|
||||
else if let info = peersSnapshot[PeerID(str: peerID)], info.isVerifiedNickname {
|
||||
// Known verified peer path
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
// Handle nickname collisions
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.prefix(4))
|
||||
}
|
||||
@@ -1690,7 +1522,7 @@ final class BLEService: NSObject {
|
||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||
// Find candidate identities by peerID prefix (16 hex)
|
||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: peerID))
|
||||
for candidate in candidates {
|
||||
if let signingKey = candidate.signingPublicKey,
|
||||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
||||
@@ -1734,9 +1566,9 @@ final class BLEService: NSObject {
|
||||
}
|
||||
// Determine if we have a direct link to the sender
|
||||
let hasDirectLink: Bool = collectionsQueue.sync {
|
||||
let perUUID = peerToPeripheralUUID[peerID]
|
||||
let perUUID = peerToPeripheralUUID[PeerID(str: peerID)]
|
||||
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
let hasCentral = centralToPeerID.values.contains(PeerID(str: peerID))
|
||||
return perConnected || hasCentral
|
||||
}
|
||||
|
||||
@@ -1745,8 +1577,7 @@ final class BLEService: NSObject {
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
>>>>>>> 7c6999c1 (Guard peer map reads on BLE message path)
|
||||
self?.delegate?.didReceivePublicMessage(from: PeerID(str: peerID), nickname: senderNickname, content: content, timestamp: ts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1761,22 +1592,22 @@ final class BLEService: NSObject {
|
||||
if peerID == myPeerID {
|
||||
accepted = true
|
||||
senderNickname = myNickname
|
||||
} else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
|
||||
} else if let info = peersSnapshot[PeerID(str: peerID)], info.isVerifiedNickname {
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.prefix(4))
|
||||
}
|
||||
} else if let info = peersSnapshot[peerID], info.isConnected {
|
||||
} else if let info = peersSnapshot[PeerID(str: peerID)], info.isConnected {
|
||||
accepted = true
|
||||
senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.prefix(4)) : info.nickname
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.prefix(4))
|
||||
}
|
||||
} else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: peerID))
|
||||
for candidate in candidates {
|
||||
if let signingKey = candidate.signingPublicKey,
|
||||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
||||
@@ -1894,7 +1725,7 @@ final class BLEService: NSObject {
|
||||
}()
|
||||
|
||||
if isPrivateMessage {
|
||||
updatePeerLastSeen(peerID)
|
||||
updatePeerLastSeen(PeerID(str: peerID))
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
@@ -1906,7 +1737,7 @@ final class BLEService: NSObject {
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivateMessage,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID
|
||||
senderPeerID: PeerID(str: peerID)
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
@@ -2257,9 +2088,7 @@ final class BLEService: NSObject {
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
@@ -2280,7 +2109,7 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
}
|
||||
|
||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||
sendPacketDirected(packet, to: peerID)
|
||||
sendPacketDirected(packet, to: peerID.id)
|
||||
}
|
||||
|
||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||
@@ -3482,282 +3311,6 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Packet Broadcasting
|
||||
|
||||
private func broadcastPacket(_ packet: BitchatPacket) {
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = padPolicy(for: packet.type)
|
||||
guard let data = packet.toBinaryData(padding: padForBLE) else {
|
||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||
return
|
||||
}
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
return
|
||||
}
|
||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
|
||||
// MARK: Broadcast helpers (single responsibility)
|
||||
private func padPolicy(for type: UInt8) -> Bool {
|
||||
switch MessageType(rawValue: type) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||||
guard let recipientID = packet.recipientID else { return }
|
||||
let recipientPeerID = PeerID(hexData: recipientID)
|
||||
var sentEncrypted = false
|
||||
|
||||
// Per-link limits for the specific peer
|
||||
var peripheralMaxLen: Int?
|
||||
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {
|
||||
if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {
|
||||
peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
}
|
||||
}
|
||||
var centralMaxLen: Int?
|
||||
do {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
|
||||
centralMaxLen = central.maximumUpdateValueLength
|
||||
}
|
||||
}
|
||||
if let pm = peripheralMaxLen, data.count > pm {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, pm - overhead)
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||||
return
|
||||
}
|
||||
if let cm = centralMaxLen, data.count > cm {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, cm - overhead)
|
||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Direct write via peripheral link
|
||||
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
|
||||
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)
|
||||
sentEncrypted = true
|
||||
}
|
||||
|
||||
// Notify via central link (dual-role)
|
||||
if let characteristic = characteristic, !sentEncrypted {
|
||||
let (centrals, mapping) = snapshotSubscribedCentrals()
|
||||
for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
|
||||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
|
||||
if success { sentEncrypted = true; break }
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
|
||||
self.pendingNotifications.append((data: data, centrals: [central]))
|
||||
SecureLogger.debug("📋 Queued encrypted packet for retry (notification queue full)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sentEncrypted {
|
||||
// Flood as last resort with recipient set; link aware
|
||||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)
|
||||
}
|
||||
|
||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
|
||||
// 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 directedPeerHint: PeerID? = {
|
||||
if let explicit = directedOnlyPeer { return explicit }
|
||||
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
|
||||
return PeerID(str: recipient)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let states = snapshotPeripheralStates()
|
||||
var minCentralWriteLen: Int?
|
||||
for s in states where s.isConnected {
|
||||
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
||||
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
|
||||
}
|
||||
var snapshotCentrals: [CBCentral] = []
|
||||
if let _ = characteristic {
|
||||
let (centrals, _) = snapshotSubscribedCentrals()
|
||||
snapshotCentrals = centrals
|
||||
}
|
||||
var minNotifyLen: Int?
|
||||
if !snapshotCentrals.isEmpty {
|
||||
minNotifyLen = snapshotCentrals.map { $0.maximumUpdateValueLength }.min()
|
||||
}
|
||||
// Avoid re-fragmenting fragment packets
|
||||
if packet.type != MessageType.fragment.rawValue,
|
||||
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(),
|
||||
data.count > minLen {
|
||||
let overhead = 13 + 8 + 8 + 13
|
||||
let chunk = max(64, minLen - overhead)
|
||||
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
|
||||
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedPeerHint == nil
|
||||
&& packet.type != MessageType.fragment.rawValue
|
||||
&& packet.type != MessageType.announce.rawValue
|
||||
&& packet.type != MessageType.requestSync.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)
|
||||
}
|
||||
|
||||
// If directed and we currently have no links to forward on, spool for a short window
|
||||
if let only = directedPeerHint,
|
||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||
}
|
||||
|
||||
// Writes to selected 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)
|
||||
}
|
||||
}
|
||||
// Notify selected 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.sendPacketDirected(packet, to: peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||
}
|
||||
|
||||
// MARK: Directed store-and-forward
|
||||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
|
||||
if byMsg[msgID] == nil {
|
||||
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
|
||||
self.pendingDirectedRelays[recipientPeerID] = byMsg
|
||||
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushDirectedSpool() {
|
||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||
let toSend: [(PeerID, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
|
||||
var out: [(PeerID, BitchatPacket)] = []
|
||||
let now = Date()
|
||||
for (recipient, dict) in pendingDirectedRelays {
|
||||
for (_, entry) in dict {
|
||||
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
|
||||
out.append((recipient, entry.packet))
|
||||
}
|
||||
}
|
||||
// Clear recipient bucket; items will be re-spooled if still no links
|
||||
pendingDirectedRelays.removeValue(forKey: recipient)
|
||||
}
|
||||
return out
|
||||
}
|
||||
for (_, packet) in toSend {
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func rebroadcastRecentAnnounces() {
|
||||
// Snapshot sender order to preserve ordering and avoid holding locks while sending
|
||||
let packets: [BitchatPacket] = collectionsQueue.sync {
|
||||
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
|
||||
}
|
||||
guard !packets.isEmpty else { return }
|
||||
for (idx, pkt) in packets.enumerated() {
|
||||
// Stagger slightly to avoid bursts
|
||||
let delayMs = idx * 20
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
||||
// Fire-and-forget: Simple send without complex fallback logic
|
||||
guard peripheral.state == .connected else { return }
|
||||
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
guard let state = peripherals[peripheralUUID],
|
||||
let characteristic = state.characteristic else { return }
|
||||
|
||||
// Fire-and-forget principle: always use .withoutResponse for speed
|
||||
// CoreBluetooth will handle fragmentation at L2CAP layer
|
||||
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
|
||||
}
|
||||
|
||||
// MARK: Fragmentation (Required for messages > BLE MTU)
|
||||
|
||||
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) {
|
||||
@@ -4420,88 +3973,9 @@ extension BLEService {
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any stored announcement for sync purposes
|
||||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||
// Send on main thread
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Helper Functions
|
||||
|
||||
private func sendLeave() {
|
||||
SecureLogger.debug("👋 Sending leave announcement", category: .session)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
ttl: messageTTL,
|
||||
senderID: myPeerID,
|
||||
payload: Data(myNickname.utf8)
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Throttle announces to prevent flooding
|
||||
let now = Date()
|
||||
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
|
||||
|
||||
// Even forced sends should respect a minimum interval to avoid overwhelming BLE
|
||||
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
||||
|
||||
if timeSinceLastAnnounce < minInterval {
|
||||
// Skipping announce (rate limited)
|
||||
return
|
||||
}
|
||||
lastAnnounceSent = now
|
||||
|
||||
// Reduced logging - only log errors, not every announce
|
||||
|
||||
// Create announce payload with both noise and signing public keys
|
||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub
|
||||
)
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil, // Will be set by signPacket below
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Sign the packet using the noise private key
|
||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
broadcastPacket(signedPacket)
|
||||
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
|
||||
private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
|
||||
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
||||
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
|
||||
|
||||
@@ -2403,7 +2403,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@MainActor
|
||||
func sendVoiceNote(at url: URL) {
|
||||
let targetPeer = selectedPrivateChatPeer
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
|
||||
@@ -2475,7 +2475,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
)
|
||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -2532,7 +2532,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer)
|
||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
@@ -2593,9 +2593,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
var chats = privateChats
|
||||
chats[peerID, default: []].append(message)
|
||||
chats[PeerID(str: peerID), default: []].append(message)
|
||||
privateChats = chats
|
||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||
trimMessagesIfNeeded()
|
||||
} else {
|
||||
let (displayName, senderPeerID) = currentPublicSender()
|
||||
message = BitchatMessage(
|
||||
@@ -2606,7 +2606,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderPeerID,
|
||||
senderPeerID: PeerID(str: senderPeerID),
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
messages.append(message)
|
||||
@@ -2635,21 +2635,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
var displaySender = nickname
|
||||
var senderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
let identity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
||||
senderPeerID = "nostr:\(shortKey)"
|
||||
senderPeerID = PeerID(str: "nostr:\(shortKey)")
|
||||
}
|
||||
return (displaySender, senderPeerID)
|
||||
return (displaySender, senderPeerID.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func nicknameForPeer(_ peerID: String) -> String {
|
||||
if let name = meshService.peerNickname(peerID: peerID) {
|
||||
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
return name
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
@@ -3825,7 +3825,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return cached.identity
|
||||
}
|
||||
// Fallback: derive and cache (should rarely happen)
|
||||
if let identity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
cachedGeohashIdentity = (ch.geohash, identity)
|
||||
return identity
|
||||
}
|
||||
@@ -4162,8 +4162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
let isSelf: Bool = {
|
||||
if let spid = message.senderPeerID {
|
||||
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
|
||||
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
||||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
}
|
||||
@@ -4190,7 +4190,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
senderStyle.foregroundColor = baseColor
|
||||
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
|
||||
if let spid = message.senderPeerID,
|
||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
|
||||
+22
-215
@@ -135,12 +135,20 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
|
||||
var body: some View {
|
||||
<<<<<<< HEAD
|
||||
VStack(spacing: 0) {
|
||||
mainHeaderView
|
||||
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||
.onAppear {
|
||||
viewModel.currentColorScheme = colorScheme
|
||||
#if os(macOS)
|
||||
// Focus message input on macOS launch, not nickname field
|
||||
DispatchQueue.main.async {
|
||||
isNicknameFieldFocused = false
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: colorScheme) { newValue in
|
||||
viewModel.currentColorScheme = newValue
|
||||
}
|
||||
@@ -150,30 +158,6 @@ struct ContentView: View {
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||
=======
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Base layer - Main public chat (always visible)
|
||||
mainChatView
|
||||
.onAppear {
|
||||
viewModel.currentColorScheme = colorScheme
|
||||
#if os(macOS)
|
||||
// Focus message input on macOS launch, not nickname field
|
||||
DispatchQueue.main.async {
|
||||
isNicknameFieldFocused = false
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: colorScheme) { newValue in
|
||||
viewModel.currentColorScheme = newValue
|
||||
}
|
||||
|
||||
// Private chat slide-over
|
||||
if viewModel.selectedPrivateChatPeer != nil {
|
||||
privateChatView
|
||||
.frame(width: geometry.size.width)
|
||||
>>>>>>> 49a22aa9 (macOS: Focus message input on launch instead of nickname field)
|
||||
.background(backgroundColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -789,7 +773,7 @@ struct ContentView: View {
|
||||
if peerID.hasPrefix("nostr") {
|
||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||
} else {
|
||||
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
||||
selectedMessageSender = name
|
||||
} else {
|
||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
@@ -1537,43 +1521,6 @@ extension ContentView {
|
||||
// MARK: - Helper Views
|
||||
|
||||
// Rounded payment chip button
|
||||
private struct PaymentChipView: View {
|
||||
let emoji: String
|
||||
let label: String
|
||||
let colorScheme: ColorScheme
|
||||
let action: () -> Void
|
||||
|
||||
private var fgColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Text(emoji)
|
||||
Text(label)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(bgColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(border, lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(fgColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
private enum MessageMedia {
|
||||
@@ -1688,7 +1635,7 @@ private extension ContentView {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
@@ -1734,7 +1681,7 @@ private extension ContentView {
|
||||
|
||||
@ViewBuilder
|
||||
private func textMessageRow(_ message: BitchatMessage) -> some View {
|
||||
let cashuTokens = message.content.extractCashuTokens()
|
||||
let cashuTokens = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
@@ -1748,7 +1695,7 @@ private extension ContentView {
|
||||
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
@@ -1766,42 +1713,14 @@ private extension ContentView {
|
||||
|
||||
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { index in
|
||||
let link = lightningLinks[index]
|
||||
PaymentChipView(
|
||||
emoji: "⚡",
|
||||
label: L10n.string(
|
||||
"content.payment.lightning",
|
||||
comment: "Label for Lightning payment chip"
|
||||
),
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: link) { UIApplication.shared.open(url) }
|
||||
#else
|
||||
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
|
||||
PaymentChipView(paymentType: .lightning(link))
|
||||
}
|
||||
|
||||
ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { index in
|
||||
let token = cashuTokens[index]
|
||||
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
|
||||
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||
let urlStr = "cashu:\(enc)"
|
||||
PaymentChipView(
|
||||
emoji: "🥜",
|
||||
label: L10n.string(
|
||||
"content.payment.cashu",
|
||||
comment: "Label for Cashu payment chip"
|
||||
),
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: urlStr) { UIApplication.shared.open(url) }
|
||||
#else
|
||||
if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
PaymentChipView(paymentType: .cashu(urlStr))
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
@@ -1941,21 +1860,12 @@ private extension ContentView {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(
|
||||
L10n.string(
|
||||
"content.accessibility.send_message",
|
||||
comment: "Accessibility label for the send message button"
|
||||
)
|
||||
String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
enabled
|
||||
? L10n.string(
|
||||
"content.accessibility.send_hint_ready",
|
||||
comment: "Hint prompting the user to send the message"
|
||||
)
|
||||
: L10n.string(
|
||||
"content.accessibility.send_hint_empty",
|
||||
comment: "Hint prompting the user to enter a message"
|
||||
)
|
||||
? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message")
|
||||
: String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2139,109 +2049,6 @@ private extension ContentView {
|
||||
|
||||
//
|
||||
|
||||
// Delivery status indicator view
|
||||
struct DeliveryStatusView: View {
|
||||
let status: DeliveryStatus
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
private var secondaryTextColor: Color {
|
||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||
}
|
||||
|
||||
private enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
L10n.string(
|
||||
"content.delivery.delivered_to",
|
||||
comment: "Tooltip for delivered private messages",
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
L10n.string(
|
||||
"content.delivery.read_by",
|
||||
comment: "Tooltip for read private messages",
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
L10n.string(
|
||||
"content.delivery.failed",
|
||||
comment: "Tooltip for failed message delivery",
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
L10n.string(
|
||||
"content.delivery.delivered_members",
|
||||
comment: "Tooltip for partially delivered messages",
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
Text("\(reached)/\(total)")
|
||||
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImagePreviewView: View {
|
||||
let url: URL
|
||||
|
||||
|
||||
Reference in New Issue
Block a user