mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:05:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e868e3bbfb |
+15
-14
@@ -49,9 +49,10 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
let nickname = chatViewModel.nickname
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
@@ -237,30 +238,30 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor [weak self] in
|
||||||
if identifier.hasPrefix("private-") {
|
guard let self = self else {
|
||||||
// Get peer ID from userInfo
|
completionHandler([.banner, .sound])
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
// Don't show notification if the private chat is already open
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
// Check if this is a private message notification and chat already open
|
||||||
|
if identifier.hasPrefix("private-"),
|
||||||
|
let peerID = userInfo["peerID"] as? String,
|
||||||
|
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
let deep = userInfo["deeplink"] as? String,
|
let deep = userInfo["deeplink"] as? String,
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
let gh = deep.components(separatedBy: "/").last,
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
|
ch.geohash == gh {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
|
|||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: PeerID)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
@@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
|
@MainActor
|
||||||
extension BitchatDelegate {
|
extension BitchatDelegate {
|
||||||
func isFavorite(fingerprint: String) -> Bool {
|
func isFavorite(fingerprint: String) -> Bool {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+170
-168
@@ -406,23 +406,35 @@ final class BLEService: NSObject {
|
|||||||
// MARK: Lifecycle
|
// MARK: Lifecycle
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
|
performOnBLEQueue { service in
|
||||||
// Start BLE services if not already running
|
// Start BLE services if not already running
|
||||||
if centralManager?.state == .poweredOn {
|
if service.centralManager?.state == .poweredOn {
|
||||||
centralManager?.scanForPeripherals(
|
service.centralManager?.scanForPeripherals(
|
||||||
withServices: [BLEService.serviceUUID],
|
withServices: [BLEService.serviceUUID],
|
||||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send initial announce after services are ready
|
// Send initial announce after services are ready.
|
||||||
// Use longer delay to avoid conflicts with other announces
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
// Send leave message synchronously to ensure delivery
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
stopServicesOnBLEQueue()
|
||||||
|
} else {
|
||||||
|
bleQueue.sync {
|
||||||
|
self.stopServicesOnBLEQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopServicesOnBLEQueue() {
|
||||||
|
assertOnBLEQueue()
|
||||||
|
// Send a final leave message to connected peers before shutting down.
|
||||||
let leavePacket = BitchatPacket(
|
let leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -433,30 +445,28 @@ final class BLEService: NSObject {
|
|||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send immediately to all connected peers
|
|
||||||
if let data = leavePacket.toBinaryData(padding: false) {
|
if let data = leavePacket.toBinaryData(padding: false) {
|
||||||
// Send to peripherals we're connected to as central
|
|
||||||
for state in peripherals.values where state.isConnected {
|
for state in peripherals.values where state.isConnected {
|
||||||
if let characteristic = state.characteristic {
|
if let characteristic = state.characteristic {
|
||||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !subscribedCentrals.isEmpty, let characteristic = characteristic {
|
||||||
// Send to centrals subscribed to us as peripheral
|
_ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
|
||||||
if subscribedCentrals.count > 0 && characteristic != nil {
|
|
||||||
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give leave message a moment to send
|
let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds
|
||||||
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
if delay > 0 {
|
||||||
|
Thread.sleep(forTimeInterval: delay)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear pending notifications
|
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingNotifications.removeAll()
|
pendingNotifications.removeAll()
|
||||||
|
pendingPeripheralWrites.removeAll()
|
||||||
|
pendingDirectedRelays.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop timer
|
|
||||||
maintenanceTimer?.cancel()
|
maintenanceTimer?.cancel()
|
||||||
maintenanceTimer = nil
|
maintenanceTimer = nil
|
||||||
scanDutyTimer?.cancel()
|
scanDutyTimer?.cancel()
|
||||||
@@ -465,10 +475,14 @@ final class BLEService: NSObject {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
peripheralManager?.stopAdvertising()
|
peripheralManager?.stopAdvertising()
|
||||||
|
|
||||||
// Disconnect all peripherals
|
|
||||||
for state in peripherals.values {
|
for state in peripherals.values {
|
||||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func emergencyDisconnectAll() {
|
func emergencyDisconnectAll() {
|
||||||
@@ -483,12 +497,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Clear processed messages
|
// Clear processed messages
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
|
|
||||||
// Clear peripheral references
|
|
||||||
peripherals.removeAll()
|
|
||||||
peerToPeripheralUUID.removeAll()
|
|
||||||
subscribedCentrals.removeAll()
|
|
||||||
centralToPeerID.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Connectivity and peers
|
// MARK: Connectivity and peers
|
||||||
@@ -702,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate {
|
|||||||
extension BLEService: CBCentralManagerDelegate {
|
extension BLEService: CBCentralManagerDelegate {
|
||||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
// Notify delegate about state change on main thread
|
// Notify delegate about state change on main thread
|
||||||
Task { @MainActor in
|
let state = central.state
|
||||||
self.delegate?.didUpdateBluetoothState(central.state)
|
notifyUI { service in
|
||||||
|
service.delegate?.didUpdateBluetoothState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
if central.state == .poweredOn {
|
if central.state == .poweredOn {
|
||||||
@@ -933,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||||
|
|
||||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
|
||||||
// Get current peer list (after removal)
|
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
if let peerID {
|
if let peerID {
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
service.notifyPeerDisconnectedDebounced(peerID)
|
||||||
}
|
}
|
||||||
self.requestPeerDataPublish()
|
service.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
centralToPeerID.removeValue(forKey: centralUUID)
|
centralToPeerID.removeValue(forKey: centralUUID)
|
||||||
|
|
||||||
// Update UI immediately
|
// Update UI immediately
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
service.notifyPeerDisconnectedDebounced(peerID)
|
||||||
// Get current peer list (after removal)
|
service.requestPeerDataPublish()
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
|
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
|
||||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
|
||||||
self.requestPeerDataPublish()
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1517,14 +1517,30 @@ extension BLEService {
|
|||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Hop to the MainActor and hand `self` to the caller for UI delegate updates.
|
||||||
private func notifyUI(_ block: @escaping () -> Void) {
|
private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) {
|
||||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
Task { @MainActor [weak self] in
|
||||||
Task { @MainActor in
|
guard let self = self else { return }
|
||||||
block()
|
block(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure work that touches CoreBluetooth objects executes on `bleQueue`
|
||||||
|
private func performOnBLEQueue(_ work: @escaping (BLEService) -> Void) {
|
||||||
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
work(self)
|
||||||
|
} else {
|
||||||
|
bleQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
work(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertOnBLEQueue(file: StaticString = #file, line: UInt = #line) {
|
||||||
|
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "BLE queue required", file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
let peerID = PeerID(str: peerID)
|
let peerID = PeerID(str: peerID)
|
||||||
@@ -1634,42 +1650,13 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||||
// BLE operations run on bleQueue; keep queue affinity
|
performOnBLEQueue { service in
|
||||||
bleQueue.async { [weak self] in
|
service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic)
|
||||||
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) {
|
|
||||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
|
||||||
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
|
||||||
let newSize = data.count
|
|
||||||
// If single chunk exceeds cap, drop it immediately
|
|
||||||
if newSize > capBytes {
|
|
||||||
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
|
||||||
} else {
|
|
||||||
// Append and trim from the front to respect cap
|
|
||||||
var total = queue.reduce(0) { $0 + $1.count }
|
|
||||||
queue.append(data)
|
|
||||||
total += newSize
|
|
||||||
if total > capBytes {
|
|
||||||
var removedBytes = 0
|
|
||||||
while total > capBytes && !queue.isEmpty {
|
|
||||||
let removed = queue.removeFirst()
|
|
||||||
removedBytes += removed.count
|
|
||||||
total -= removed.count
|
|
||||||
}
|
|
||||||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
|
||||||
}
|
|
||||||
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||||
|
assertOnBLEQueue()
|
||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -1702,6 +1689,38 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func writeOrEnqueueOnBLEQueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||||
|
let uuid = peripheral.identifier.uuidString
|
||||||
|
if peripheral.canSendWriteWithoutResponse {
|
||||||
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionsQueue.async(flags: .barrier) {
|
||||||
|
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||||
|
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
let newSize = data.count
|
||||||
|
if newSize > capBytes {
|
||||||
|
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = queue.reduce(0) { $0 + $1.count }
|
||||||
|
queue.append(data)
|
||||||
|
total += newSize
|
||||||
|
if total > capBytes {
|
||||||
|
var removedBytes = 0
|
||||||
|
while total > capBytes && !queue.isEmpty {
|
||||||
|
let removed = queue.removeFirst()
|
||||||
|
removedBytes += removed.count
|
||||||
|
total -= removed.count
|
||||||
|
}
|
||||||
|
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
||||||
|
}
|
||||||
|
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Application State Handlers (iOS)
|
// MARK: Application State Handlers (iOS)
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -1784,8 +1803,8 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify delegate that message was sent
|
// Notify delegate that message was sent
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to encrypt message: \(error)")
|
SecureLogger.error("Failed to encrypt message: \(error)")
|
||||||
@@ -1805,8 +1824,8 @@ extension BLEService {
|
|||||||
initiateNoiseHandshake(with: recipientID)
|
initiateNoiseHandshake(with: recipientID)
|
||||||
|
|
||||||
// Notify delegate that message is pending
|
// Notify delegate that message is pending
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1882,8 +1901,8 @@ extension BLEService {
|
|||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
|
|
||||||
// Notify delegate that message was sent
|
// Notify delegate that message was sent
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
||||||
@@ -1891,8 +1910,8 @@ extension BLEService {
|
|||||||
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
||||||
|
|
||||||
// Notify delegate of failure
|
// Notify delegate of failure
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1907,11 +1926,13 @@ extension BLEService {
|
|||||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
performOnBLEQueue { service in
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
service.sendEncrypted(packet, data: data, pad: padForBLE)
|
||||||
return
|
} else {
|
||||||
|
service.sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Broadcast helpers (single responsibility)
|
// MARK: Broadcast helpers (single responsibility)
|
||||||
@@ -2550,21 +2571,15 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify UI on main thread
|
// Notify UI on main thread
|
||||||
notifyUI { [weak self] in
|
let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer)
|
||||||
guard let self = self else { return }
|
notifyUI { service in
|
||||||
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
// Get current peer list (after addition)
|
if isDirectAnnounce {
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
service.delegate?.didConnectToPeer(peerID)
|
||||||
|
service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||||
// 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)
|
|
||||||
// Schedule initial unicast sync to this peer
|
|
||||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
|
||||||
}
|
}
|
||||||
|
service.requestPeerDataPublish()
|
||||||
self.requestPeerDataPublish()
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track for sync (include our own and others' announces)
|
// Track for sync (include our own and others' announces)
|
||||||
@@ -2636,17 +2651,26 @@ extension BLEService {
|
|||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = myNickname
|
senderNickname = myNickname
|
||||||
|
} else {
|
||||||
|
let selfNickname = myNickname
|
||||||
|
let peerLookup = collectionsQueue.sync { () -> (PeerInfo?, Bool) in
|
||||||
|
guard let info = peers[peerID] else { return (nil, false) }
|
||||||
|
let collision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||||
}
|
}
|
||||||
else if let info = peers[peerID], info.isVerifiedNickname {
|
return (info, collision)
|
||||||
// Known verified peer path
|
}
|
||||||
|
if let info = peerLookup.0, info.isVerifiedNickname {
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = info.nickname
|
senderNickname = info.nickname
|
||||||
// Handle nickname collisions
|
let hasCollision = peerLookup.1 || (selfNickname == info.nickname)
|
||||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
|
||||||
if hasCollision {
|
if hasCollision {
|
||||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !accepted {
|
||||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||||
// Find candidate identities by peerID prefix (16 hex)
|
// Find candidate identities by peerID prefix (16 hex)
|
||||||
@@ -2704,8 +2728,8 @@ extension BLEService {
|
|||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2770,28 +2794,28 @@ extension BLEService {
|
|||||||
switch NoisePayloadType(rawValue: payloadType) {
|
switch NoisePayloadType(rawValue: payloadType) {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .delivered:
|
case .delivered:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||||
@@ -2816,14 +2840,10 @@ extension BLEService {
|
|||||||
// Remove any stored announcement for sync purposes
|
// Remove any stored announcement for sync purposes
|
||||||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
// Send on main thread
|
// Send on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
service.delegate?.didDisconnectFromPeer(peerID)
|
||||||
// Get current peer list (after removal)
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
self.delegate?.didDisconnectFromPeer(peerID)
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2841,24 +2861,20 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
performOnBLEQueue { service in
|
||||||
let now = Date()
|
service.sendAnnounceOnBLEQueue(forceSend: forceSend)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAnnounceOnBLEQueue(forceSend: Bool) {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastAnnounceSent)
|
||||||
|
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
||||||
|
guard elapsed >= minInterval else { return }
|
||||||
lastAnnounceSent = now
|
lastAnnounceSent = now
|
||||||
|
|
||||||
// Reduced logging - only log errors, not every announce
|
let noisePub = noiseService.getStaticPublicKeyData()
|
||||||
|
let signingPub = noiseService.getSigningPublicKeyData()
|
||||||
// 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(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
@@ -2871,32 +2887,22 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create packet with signature using the noise private key
|
let unsigned = BitchatPacket(
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Will be set by signPacket below
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the packet using the noise private key
|
guard let signedPacket = noiseService.signPacket(unsigned) else {
|
||||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
|
||||||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(signedPacket)
|
broadcastPacket(signedPacket)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(signedPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure our own announce is included in sync state
|
|
||||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2938,6 +2944,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||||
|
@MainActor
|
||||||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = recentDisconnectNotifies[peerID]
|
let last = recentDisconnectNotifies[peerID]
|
||||||
@@ -3082,18 +3089,13 @@ extension BLEService {
|
|||||||
|
|
||||||
// Update UI if there were direct disconnections or offline removals
|
// Update UI if there were direct disconnections or offline removals
|
||||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
|
||||||
// Get current peer list (after removal)
|
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
for peerID in disconnectedPeers {
|
for peerID in disconnectedPeers {
|
||||||
self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
service.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
service.requestPeerDataPublish()
|
||||||
self.requestPeerDataPublish()
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
|
private static let noiseServiceLock = NSLock()
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
if let noiseService = Self.cachedNoiseService {
|
Self.noiseServiceLock.lock()
|
||||||
return noiseService
|
defer { Self.noiseServiceLock.unlock() }
|
||||||
}
|
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
Self.cachedNoiseService = noiseService
|
Self.cachedNoiseService = noiseService
|
||||||
return noiseService
|
return noiseService
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import UIKit
|
|||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
|
@MainActor
|
||||||
final class ChatViewModel: ObservableObject, BitchatDelegate {
|
final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||||
// Precompiled regexes and detectors reused across formatting
|
// Precompiled regexes and detectors reused across formatting
|
||||||
private enum Regexes {
|
private enum Regexes {
|
||||||
@@ -229,14 +230,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set
|
|
||||||
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed != nickname {
|
if trimmed != nickname {
|
||||||
nickname = trimmed
|
nickname = trimmed
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
if meshService.myPeerID != "", meshService.myNickname != trimmed {
|
||||||
if meshService.myPeerID != "" {
|
meshService.setNickname(trimmed)
|
||||||
meshService.setNickname(nickname)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import UIKit
|
|||||||
|
|
||||||
// MARK: - Main Content View
|
// MARK: - Main Content View
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
private struct ScrollRequest: Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
let target: String
|
||||||
|
}
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@@ -46,7 +51,8 @@ struct ContentView: View {
|
|||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@State private var lastScrollTime: Date = .distantPast
|
@State private var lastScrollTime: Date = .distantPast
|
||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTask: Task<Void, Never>?
|
||||||
|
@State private var pendingScrollRequest: ScrollRequest?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
@State private var showVerifySheet = false
|
@State private var showVerifySheet = false
|
||||||
@@ -80,6 +86,30 @@ struct ContentView: View {
|
|||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func enqueueScroll(target: String) {
|
||||||
|
pendingScrollRequest = ScrollRequest(target: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleScroll(target: String, delay: TimeInterval) {
|
||||||
|
scrollThrottleTask?.cancel()
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
if delay <= 0 {
|
||||||
|
lastScrollTime = Date()
|
||||||
|
enqueueScroll(target: target)
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nanoseconds = UInt64(max(delay, 0) * 1_000_000_000)
|
||||||
|
scrollThrottleTask = Task { [target] in
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
await MainActor.run {
|
||||||
|
lastScrollTime = Date()
|
||||||
|
enqueueScroll(target: target)
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -296,8 +326,8 @@ struct ContentView: View {
|
|||||||
Text(viewModel.bluetoothAlertMessage)
|
Text(viewModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Clean up timers
|
scrollThrottleTask?.cancel()
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTask = nil
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,6 +452,11 @@ struct ContentView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.onChange(of: pendingScrollRequest) { request in
|
||||||
|
guard let request else { return }
|
||||||
|
proxy.scrollTo(request.target, anchor: .bottom)
|
||||||
|
pendingScrollRequest = nil
|
||||||
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
@@ -543,44 +578,27 @@ struct ContentView: View {
|
|||||||
let lastMsg = viewModel.messages.last!
|
let lastMsg = viewModel.messages.last!
|
||||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
if !isFromSelf {
|
if !isFromSelf {
|
||||||
// Only autoscroll when user is at/near bottom
|
|
||||||
guard isAtBottom.wrappedValue else { return }
|
guard isAtBottom.wrappedValue else { return }
|
||||||
} else {
|
} else {
|
||||||
// Ensure we consider ourselves at bottom for subsequent messages
|
|
||||||
isAtBottom.wrappedValue = true
|
isAtBottom.wrappedValue = true
|
||||||
}
|
}
|
||||||
// Throttle scroll animations to prevent excessive UI updates
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
let contextKey: String = {
|
||||||
// Immediate scroll if enough time has passed
|
switch locationManager.selectedChannel {
|
||||||
|
case .mesh: return "mesh"
|
||||||
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let count = windowCountPublic
|
||||||
|
if let target = viewModel.messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||||
|
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||||
|
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||||
|
scrollThrottleTask?.cancel()
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey: String = {
|
enqueueScroll(target: target)
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let count = windowCountPublic
|
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Schedule a delayed scroll
|
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||||
scrollThrottleTimer?.invalidate()
|
scheduleScroll(target: target, delay: remaining)
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
|
||||||
lastScrollTime = Date()
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let count = windowCountPublic
|
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,26 +616,18 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
isAtBottom.wrappedValue = true
|
isAtBottom.wrappedValue = true
|
||||||
}
|
}
|
||||||
// Same throttling for private chats
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
let contextKey = "dm:\(peerID)"
|
||||||
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||||
|
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||||
|
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||||
|
scrollThrottleTask?.cancel()
|
||||||
lastScrollTime = now
|
lastScrollTime = now
|
||||||
let contextKey = "dm:\(peerID)"
|
enqueueScroll(target: target)
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scheduleScroll(target: target, delay: remaining)
|
||||||
lastScrollTime = Date()
|
|
||||||
let contextKey = "dm:\(peerID)"
|
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,10 +801,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||||
|
Task { @MainActor in
|
||||||
// Get cursor position (approximate - end of text for now)
|
// Get cursor position (approximate - end of text for now)
|
||||||
let cursorPosition = newValue.count
|
let cursorPosition = newValue.count
|
||||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for command autocomplete (instant, no debounce needed)
|
// Check for command autocomplete (instant, no debounce needed)
|
||||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user