mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:45:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e868e3bbfb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 |
+1
-1
@@ -6,7 +6,7 @@ let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
|
||||
+15
-14
@@ -49,9 +49,10 @@ struct BitchatApp: App {
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
let nickname = chatViewModel.nickname
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
@@ -237,30 +238,30 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else {
|
||||
completionHandler([.banner, .sound])
|
||||
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
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
let gh = deep.components(separatedBy: "/").last,
|
||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
|
||||
@@ -335,20 +335,6 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System Message Factory
|
||||
|
||||
extension BitchatMessage {
|
||||
/// Creates a system message with default values
|
||||
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
|
||||
return BitchatMessage(
|
||||
sender: "system",
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == BitchatMessage {
|
||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||
func cleanedAndDeduped() -> [Element] {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// GeoPerson.swift
|
||||
// bitchat
|
||||
//
|
||||
// Model representing a participant in a geohash channel
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a person participating in a geohash-based location channel
|
||||
struct GeoPerson: Identifiable, Equatable {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
let lastSeen: Date
|
||||
}
|
||||
@@ -110,19 +110,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up timers and active connections
|
||||
reconnectionTimer?.invalidate()
|
||||
for (_, tracker) in eoseTrackers {
|
||||
tracker.timer?.invalidate()
|
||||
}
|
||||
for (_, task) in connections {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
|
||||
@@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
@MainActor
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
@@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@MainActor
|
||||
extension BitchatDelegate {
|
||||
func isFavorite(fingerprint: String) -> Bool {
|
||||
return false
|
||||
|
||||
+170
-168
@@ -406,23 +406,35 @@ final class BLEService: NSObject {
|
||||
// MARK: Lifecycle
|
||||
|
||||
func startServices() {
|
||||
performOnBLEQueue { service in
|
||||
// Start BLE services if not already running
|
||||
if centralManager?.state == .poweredOn {
|
||||
centralManager?.scanForPeripherals(
|
||||
if service.centralManager?.state == .poweredOn {
|
||||
service.centralManager?.scanForPeripherals(
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial announce after services are ready
|
||||
// Use longer delay to avoid conflicts with other announces
|
||||
// Send initial announce after services are ready.
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
@@ -433,30 +445,28 @@ final class BLEService: NSObject {
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Send immediately to all connected peers
|
||||
if let data = leavePacket.toBinaryData(padding: false) {
|
||||
// Send to peripherals we're connected to as central
|
||||
for state in peripherals.values where state.isConnected {
|
||||
if let characteristic = state.characteristic {
|
||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// Send to centrals subscribed to us as peripheral
|
||||
if subscribedCentrals.count > 0 && characteristic != nil {
|
||||
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
|
||||
if !subscribedCentrals.isEmpty, let characteristic = characteristic {
|
||||
_ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Give leave message a moment to send
|
||||
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
||||
let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds
|
||||
if delay > 0 {
|
||||
Thread.sleep(forTimeInterval: delay)
|
||||
}
|
||||
|
||||
// Clear pending notifications
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
}
|
||||
|
||||
// Stop timer
|
||||
maintenanceTimer?.cancel()
|
||||
maintenanceTimer = nil
|
||||
scanDutyTimer?.cancel()
|
||||
@@ -465,10 +475,14 @@ final class BLEService: NSObject {
|
||||
centralManager?.stopScan()
|
||||
peripheralManager?.stopAdvertising()
|
||||
|
||||
// Disconnect all peripherals
|
||||
for state in peripherals.values {
|
||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||
}
|
||||
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
func emergencyDisconnectAll() {
|
||||
@@ -483,12 +497,6 @@ final class BLEService: NSObject {
|
||||
|
||||
// Clear processed messages
|
||||
messageDeduplicator.reset()
|
||||
|
||||
// Clear peripheral references
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Connectivity and peers
|
||||
@@ -702,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
// Notify delegate about state change on main thread
|
||||
Task { @MainActor in
|
||||
self.delegate?.didUpdateBluetoothState(central.state)
|
||||
let state = central.state
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateBluetoothState(state)
|
||||
}
|
||||
|
||||
if central.state == .poweredOn {
|
||||
@@ -933,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
|
||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
if let peerID {
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
service.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
centralToPeerID.removeValue(forKey: centralUUID)
|
||||
|
||||
// Update UI immediately
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
service.notifyPeerDisconnectedDebounced(peerID)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1517,14 +1517,30 @@ extension BLEService {
|
||||
|
||||
extension BLEService {
|
||||
|
||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||
private func notifyUI(_ block: @escaping () -> Void) {
|
||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
||||
Task { @MainActor in
|
||||
block()
|
||||
/// Hop to the MainActor and hand `self` to the caller for UI delegate updates.
|
||||
private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
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) {
|
||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
let peerID = PeerID(str: peerID)
|
||||
@@ -1634,42 +1650,13 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||
// BLE operations run on bleQueue; keep queue affinity
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
if peripheral.canSendWriteWithoutResponse {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
} else {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
performOnBLEQueue { service in
|
||||
service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||
assertOnBLEQueue()
|
||||
let uuid = peripheral.identifier.uuidString
|
||||
bleQueue.async { [weak self] in
|
||||
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)
|
||||
|
||||
#if os(iOS)
|
||||
@@ -1784,8 +1803,8 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// Notify delegate that message was sent
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to encrypt message: \(error)")
|
||||
@@ -1805,8 +1824,8 @@ extension BLEService {
|
||||
initiateNoiseHandshake(with: recipientID)
|
||||
|
||||
// Notify delegate that message is pending
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1882,8 +1901,8 @@ extension BLEService {
|
||||
broadcastPacket(packet)
|
||||
|
||||
// Notify delegate that message was sent
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
notifyUI { service in
|
||||
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||
}
|
||||
|
||||
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)")
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||
notifyUI { service in
|
||||
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)
|
||||
return
|
||||
}
|
||||
performOnBLEQueue { service in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
return
|
||||
service.sendEncrypted(packet, data: data, pad: padForBLE)
|
||||
} else {
|
||||
service.sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
}
|
||||
sendGenericBroadcast(packet, data: data, pad: padForBLE)
|
||||
}
|
||||
|
||||
// MARK: Broadcast helpers (single responsibility)
|
||||
@@ -2550,21 +2571,15 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// 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 { self.currentPeerIDs }
|
||||
|
||||
// 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)
|
||||
let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
if isDirectAnnounce {
|
||||
service.delegate?.didConnectToPeer(peerID)
|
||||
service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||
}
|
||||
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
|
||||
// Track for sync (include our own and others' announces)
|
||||
@@ -2636,17 +2651,26 @@ extension BLEService {
|
||||
if peerID == myPeerID {
|
||||
accepted = true
|
||||
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 {
|
||||
// Known verified peer path
|
||||
return (info, collision)
|
||||
}
|
||||
if let info = peerLookup.0, info.isVerifiedNickname {
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
// Handle nickname collisions
|
||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
|
||||
let hasCollision = peerLookup.1 || (selfNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
if !accepted {
|
||||
// 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)
|
||||
@@ -2704,8 +2728,8 @@ extension BLEService {
|
||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2770,28 +2794,28 @@ extension BLEService {
|
||||
switch NoisePayloadType(rawValue: payloadType) {
|
||||
case .privateMessage:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .delivered:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .readReceipt:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyChallenge:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
case .verifyResponse:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
notifyUI { service in
|
||||
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||
}
|
||||
default:
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
@@ -2816,14 +2840,10 @@ extension BLEService {
|
||||
// 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)
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
service.delegate?.didDisconnectFromPeer(peerID)
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2841,24 +2861,20 @@ extension BLEService {
|
||||
}
|
||||
|
||||
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
|
||||
performOnBLEQueue { service in
|
||||
service.sendAnnounceOnBLEQueue(forceSend: forceSend)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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 noisePub = noiseService.getStaticPublicKeyData()
|
||||
let signingPub = noiseService.getSigningPublicKeyData()
|
||||
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: myNickname,
|
||||
@@ -2871,32 +2887,22 @@ extension BLEService {
|
||||
return
|
||||
}
|
||||
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
let unsigned = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil, // Will be set by signPacket below
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// Sign the packet using the noise private key
|
||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||||
guard let signedPacket = noiseService.signPacket(unsigned) else {
|
||||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Call directly if on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(signedPacket)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
|
||||
@@ -2938,6 +2944,7 @@ extension BLEService {
|
||||
}
|
||||
|
||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||
@MainActor
|
||||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||||
let now = Date()
|
||||
let last = recentDisconnectNotifies[peerID]
|
||||
@@ -3082,18 +3089,13 @@ extension BLEService {
|
||||
|
||||
// Update UI if there were direct disconnections or offline removals
|
||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||
notifyUI { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
||||
|
||||
notifyUI { service in
|
||||
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||
for peerID in disconnectedPeers {
|
||||
self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||
service.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||
}
|
||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||||
self.requestPeerDataPublish()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
service.requestPeerDataPublish()
|
||||
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
//
|
||||
// ColorPaletteService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages consistent color assignment for peers using minimal-distance algorithm
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service that assigns consistent, visually distinct colors to peers
|
||||
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
|
||||
final class ColorPaletteService {
|
||||
|
||||
// MARK: - Palette State
|
||||
|
||||
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||
|
||||
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let slotCount: Int
|
||||
private let avoidCenter: Double // Hue to avoid (typically orange for self)
|
||||
private let avoidDelta: Double
|
||||
private let saturationDark: Double
|
||||
private let saturationLight: Double
|
||||
private let baseBrightnessDark: Double
|
||||
private let baseBrightnessLight: Double
|
||||
private let ringDeltaDark: Double
|
||||
private let ringDeltaLight: Double
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
|
||||
avoidCenter: Double = 30.0 / 360.0, // Orange hue
|
||||
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
|
||||
saturationDark: Double = 0.80,
|
||||
saturationLight: Double = 0.70,
|
||||
baseBrightnessDark: Double = 0.75,
|
||||
baseBrightnessLight: Double = 0.45,
|
||||
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
|
||||
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
) {
|
||||
self.slotCount = slotCount
|
||||
self.avoidCenter = avoidCenter
|
||||
self.avoidDelta = avoidDelta
|
||||
self.saturationDark = saturationDark
|
||||
self.saturationLight = saturationLight
|
||||
self.baseBrightnessDark = baseBrightnessDark
|
||||
self.baseBrightnessLight = baseBrightnessLight
|
||||
self.ringDeltaDark = ringDeltaDark
|
||||
self.ringDeltaLight = ringDeltaLight
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Get color for a mesh peer
|
||||
func colorForMeshPeer(
|
||||
peerID: String,
|
||||
isDark: Bool,
|
||||
myPeerID: String,
|
||||
allPeers: [BitchatPeer],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) -> Color {
|
||||
// Ensure palette is up to date
|
||||
rebuildPeerPaletteIfNeeded(
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
|
||||
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
||||
let orange = Color.orange
|
||||
if peerID == myPeerID { return orange }
|
||||
|
||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
// Fallback to seed color if not in palette
|
||||
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||
return Color(peerSeed: seed, isDark: isDark)
|
||||
}
|
||||
|
||||
/// Get color for a Nostr participant
|
||||
func colorForNostrPubkey(
|
||||
pubkeyHexLowercased: String,
|
||||
isDark: Bool,
|
||||
myNostrPubkey: String?,
|
||||
geohashPeople: [(id: String, seed: String)]
|
||||
) -> Color {
|
||||
rebuildNostrPaletteIfNeeded(
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
geohashPeople: geohashPeople
|
||||
)
|
||||
|
||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
|
||||
|
||||
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
|
||||
// Fallback to seed color
|
||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||
}
|
||||
|
||||
/// Get color for a message sender (auto-detects type)
|
||||
func peerColor(
|
||||
for message: BitchatMessage,
|
||||
isDark: Bool,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
nostrKeyMapping: [String: String],
|
||||
allPeers: [BitchatPeer],
|
||||
geohashPeople: [(id: String, seed: String)],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) -> Color {
|
||||
if let spid = message.senderPeerID?.id {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
let bare: String = {
|
||||
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||
return spid
|
||||
}()
|
||||
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||
return colorForNostrPubkey(
|
||||
pubkeyHexLowercased: full,
|
||||
isDark: isDark,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
geohashPeople: geohashPeople
|
||||
)
|
||||
} else if spid.count == 16 {
|
||||
return colorForMeshPeer(
|
||||
peerID: spid,
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
} else {
|
||||
return colorForMeshPeer(
|
||||
peerID: spid.lowercased(),
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
allPeers: allPeers,
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
}
|
||||
}
|
||||
// Fallback when we only have a display name
|
||||
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||
}
|
||||
|
||||
/// Reset all palette state (useful for testing)
|
||||
func reset() {
|
||||
peerPaletteLight.removeAll()
|
||||
peerPaletteDark.removeAll()
|
||||
peerPaletteSeeds.removeAll()
|
||||
nostrPaletteLight.removeAll()
|
||||
nostrPaletteDark.removeAll()
|
||||
nostrPaletteSeeds.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
|
||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||
return "noise:" + full
|
||||
}
|
||||
return peerID.lowercased()
|
||||
}
|
||||
|
||||
private func rebuildPeerPaletteIfNeeded(
|
||||
myPeerID: String,
|
||||
allPeers: [BitchatPeer],
|
||||
getNoiseKeyForShortID: (String) -> String?
|
||||
) {
|
||||
// Build current peer->seed map (excluding self)
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in allPeers where p.peerID.id != myPeerID {
|
||||
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||
}
|
||||
|
||||
// If seeds unchanged and palette exists for both themes, skip
|
||||
if currentSeeds == peerPaletteSeeds,
|
||||
peerPaletteLight.keys.count == currentSeeds.count,
|
||||
peerPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
peerPaletteSeeds = currentSeeds
|
||||
|
||||
// Generate palette
|
||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
|
||||
peerPaletteLight = mapping
|
||||
peerPaletteDark = mapping
|
||||
}
|
||||
|
||||
private func rebuildNostrPaletteIfNeeded(
|
||||
myNostrPubkey: String?,
|
||||
geohashPeople: [(id: String, seed: String)]
|
||||
) {
|
||||
// Build seeds map from currently visible geohash people (excluding self)
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in geohashPeople where p.id != myNostrPubkey {
|
||||
currentSeeds[p.id] = p.seed
|
||||
}
|
||||
|
||||
if currentSeeds == nostrPaletteSeeds,
|
||||
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
nostrPaletteSeeds = currentSeeds
|
||||
|
||||
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
|
||||
nostrPaletteLight = mapping
|
||||
nostrPaletteDark = mapping
|
||||
}
|
||||
|
||||
// MARK: - Minimal-Distance Color Assignment Algorithm
|
||||
|
||||
private func assignColorsMinimalDistance(
|
||||
seeds: [String: String],
|
||||
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
|
||||
) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||
// Generate evenly spaced hue slots avoiding self-orange range
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
// Helper to compute circular distance
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
// Assign slots to peers to maximize minimal distance, deterministically
|
||||
let peers = seeds.keys.sorted() // stable order
|
||||
|
||||
// Preferred slot index by seed (wrapping to available slots)
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = (seeds[id] ?? id).djb2()
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
// Keep previous assignments if still valid to minimize churn
|
||||
for (id, entry) in previousMapping {
|
||||
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
// First ring assignment using free slots
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
// If a preferred slot free, take it
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
// Choose free slot maximizing minimal distance to used hues
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
// Bias toward preferred index for stability
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
// Spread over slots by rotating from preferred with a golden-step
|
||||
let goldenStep = 7 // small prime step for dispersion
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//
|
||||
// DeliveryTrackingService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Service for tracking message delivery and read status
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Service that manages delivery status updates for messages
|
||||
/// Prevents status downgrades (e.g., read → delivered) and maintains consistency
|
||||
final class DeliveryTrackingService {
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Update delivery status for a message, preventing downgrades
|
||||
/// - Parameters:
|
||||
/// - messageID: The message ID to update
|
||||
/// - status: The new delivery status
|
||||
/// - messages: Array of public messages (inout for mutation)
|
||||
/// - privateChats: Dictionary of private chats (inout for mutation)
|
||||
/// - notifyChange: Closure to trigger UI update
|
||||
func updateStatus(
|
||||
messageID: String,
|
||||
status: DeliveryStatus,
|
||||
messages: inout [BitchatMessage],
|
||||
privateChats: inout [String: [BitchatMessage]],
|
||||
notifyChange: @escaping () -> Void
|
||||
) {
|
||||
// Update in main messages
|
||||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
let currentStatus = messages[index].deliveryStatus
|
||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||
messages[index].deliveryStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
// Update in private chats
|
||||
for (peerID, chatMessages) in privateChats {
|
||||
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||||
|
||||
let currentStatus = chatMessages[index].deliveryStatus
|
||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||
|
||||
// Update delivery status
|
||||
privateChats[peerID]?[index].deliveryStatus = status
|
||||
}
|
||||
|
||||
// Trigger UI update
|
||||
DispatchQueue.main.async {
|
||||
notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Check if we should skip a status update to prevent downgrades
|
||||
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||
guard let current = currentStatus else { return false }
|
||||
|
||||
// Don't downgrade from read to delivered or sent
|
||||
switch (current, newStatus) {
|
||||
case (.read, .delivered):
|
||||
return true
|
||||
case (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,6 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
.assign(to: &$mutualFavorites)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
/// Add or update a favorite
|
||||
func addFavorite(
|
||||
peerNoisePublicKey: Data,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
@@ -15,8 +16,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
@@ -25,12 +28,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
load()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Cancel any pending geocoding operations
|
||||
geocoder.cancelGeocode()
|
||||
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
@@ -121,6 +118,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
@@ -151,8 +149,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
@@ -215,6 +215,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//
|
||||
// GeohashParticipantsService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages tracking of participants in geohash-based location channels
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Service for tracking and managing participants in geohash channels
|
||||
/// Handles automatic expiration, refresh timers, and participant list management
|
||||
final class GeohashParticipantsService: ObservableObject {
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var geohashPeople: [GeoPerson] = []
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
|
||||
private var geoParticipantsTimer: Timer? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let displayNameProvider: (String) -> String
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let activityWindowSeconds: TimeInterval
|
||||
private let refreshIntervalSeconds: TimeInterval
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
displayNameProvider: @escaping (String) -> String,
|
||||
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
|
||||
refreshIntervalSeconds: TimeInterval = 30.0
|
||||
) {
|
||||
self.identityManager = identityManager
|
||||
self.displayNameProvider = displayNameProvider
|
||||
self.activityWindowSeconds = activityWindowSeconds
|
||||
self.refreshIntervalSeconds = refreshIntervalSeconds
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor methods
|
||||
// Timer cleanup will happen automatically when service is deallocated
|
||||
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Set the current geohash being tracked (starts/stops timer accordingly)
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
if currentGeohash != geohash {
|
||||
currentGeohash = geohash
|
||||
refreshPeopleList()
|
||||
|
||||
if geohash != nil {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a participant activity in the current geohash
|
||||
func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = currentGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
}
|
||||
|
||||
/// Record a participant activity in a specific geohash
|
||||
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = geoParticipants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
geoParticipants[geohash] = map
|
||||
|
||||
// Only refresh list if this geohash is currently selected
|
||||
if currentGeohash == geohash {
|
||||
refreshPeopleList()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get visible people for the current geohash (without mutating state)
|
||||
func visiblePeople() -> [GeoPerson] {
|
||||
guard let gh = currentGeohash else { return [] }
|
||||
return visiblePeople(for: gh)
|
||||
}
|
||||
|
||||
/// Get visible people for a specific geohash
|
||||
func visiblePeople(for geohash: String) -> [GeoPerson] {
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
let map = (geoParticipants[geohash] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
let people = map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
return people
|
||||
}
|
||||
|
||||
/// Get participant count for a specific geohash (using activity window)
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
let map = geoParticipants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (e.g., when blocked)
|
||||
func removeParticipant(pubkeyHexLowercased: String) {
|
||||
let hex = pubkeyHexLowercased.lowercased()
|
||||
for (gh, var map) in geoParticipants {
|
||||
map.removeValue(forKey: hex)
|
||||
geoParticipants[gh] = map
|
||||
}
|
||||
refreshPeopleList()
|
||||
}
|
||||
|
||||
/// Clear all participant data (for testing or reset)
|
||||
func reset() {
|
||||
stopTimer()
|
||||
geoParticipants.removeAll()
|
||||
geohashPeople.removeAll()
|
||||
currentGeohash = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func refreshPeopleList() {
|
||||
guard let gh = currentGeohash else {
|
||||
geohashPeople = []
|
||||
return
|
||||
}
|
||||
|
||||
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||
var map = geoParticipants[gh] ?? [:]
|
||||
|
||||
// Prune expired entries
|
||||
map = map.filter { $0.value >= cutoff }
|
||||
|
||||
// Remove blocked Nostr pubkeys
|
||||
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||
|
||||
// Update cleaned map
|
||||
geoParticipants[gh] = map
|
||||
|
||||
// Build display list
|
||||
let people = map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
|
||||
geohashPeople = people
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.refreshPeopleList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
geoParticipantsTimer?.invalidate()
|
||||
geoParticipantsTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,6 @@ final class LocationNotesCounter: ObservableObject {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor functions
|
||||
// Subscription cleanup will happen automatically when counter is deallocated
|
||||
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
|
||||
@@ -101,12 +101,6 @@ final class LocationNotesManager: ObservableObject {
|
||||
subscribe()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Note: deinit cannot call @MainActor functions
|
||||
// Subscription cleanup will happen automatically when manager is deallocated
|
||||
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func setGeohash(_ newGeohash: String) {
|
||||
let norm = newGeohash.lowercased()
|
||||
guard norm != geohash else { return }
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
//
|
||||
// MessageFormattingService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Service for formatting chat messages with syntax highlighting
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Service that formats BitchatMessages into styled AttributedStrings
|
||||
/// Handles hashtags, mentions, links, payment tokens, and more
|
||||
final class MessageFormattingService {
|
||||
|
||||
// MARK: - Precompiled Regexes
|
||||
|
||||
private enum Regexes {
|
||||
static let hashtag: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||
}()
|
||||
static let mention: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||
}()
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let linkDetector: NSDataDetector? = {
|
||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
}()
|
||||
static let quickCashuPresence: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let colorPalette: ColorPaletteService
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(colorPalette: ColorPaletteService) {
|
||||
self.colorPalette = colorPalette
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
|
||||
/// This is the primary formatter used in the main chat view
|
||||
func formatMessageAsText(
|
||||
_ message: BitchatMessage,
|
||||
colorScheme: ColorScheme,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID,
|
||||
nostrKeyMapping: [String: String],
|
||||
allPeers: [BitchatPeer],
|
||||
geohashPeople: [GeoPerson],
|
||||
getNoiseKeyForShortID: @escaping (String) -> String?
|
||||
) -> AttributedString {
|
||||
// Determine if this message was sent by self
|
||||
let isSelf = isSelfMessage(
|
||||
message,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
)
|
||||
|
||||
// Check cache first
|
||||
let isDark = colorScheme == .dark
|
||||
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||
return cachedText
|
||||
}
|
||||
|
||||
// Not cached, format the message
|
||||
var result = AttributedString()
|
||||
|
||||
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
|
||||
for: message,
|
||||
isDark: isDark,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
nostrKeyMapping: nostrKeyMapping,
|
||||
allPeers: allPeers,
|
||||
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
|
||||
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||
)
|
||||
|
||||
if message.sender != "system" {
|
||||
// Sender (at the beginning) with light-gray suffix styling if present
|
||||
let (baseName, suffix) = message.sender.splitSuffix()
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = baseColor
|
||||
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
||||
|
||||
// Make sender clickable: encode senderPeerID into a custom URL
|
||||
if let spid = message.senderPeerID?.id,
|
||||
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||
senderStyle.link = url
|
||||
}
|
||||
|
||||
// Format: <@name#suffix>
|
||||
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||
if !suffix.isEmpty {
|
||||
var suffixStyle = senderStyle
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
// Process content with syntax highlighting
|
||||
let content = message.content
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
|
||||
// Check for Cashu presence early to decide rendering strategy
|
||||
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
|
||||
in: content,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: nsLen)
|
||||
) > 0
|
||||
|
||||
// For extremely long content, render as plain text (unless has Cashu)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||
var plainStyle = AttributeContainer()
|
||||
plainStyle.foregroundColor = baseColor
|
||||
plainStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||
} else {
|
||||
// Full syntax highlighting
|
||||
result.append(formatContent(
|
||||
content,
|
||||
nsContent: nsContent,
|
||||
nsLen: nsLen,
|
||||
message: message,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isDark: isDark,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
))
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
} else {
|
||||
// System message
|
||||
var contentStyle = AttributeContainer()
|
||||
contentStyle.foregroundColor = Color.gray
|
||||
let content = AttributedString("* \(message.content) *")
|
||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||
result.append(content.mergingAttributes(contentStyle))
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
|
||||
// Cache the formatted text
|
||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Simpler message formatter (used in legacy contexts)
|
||||
func formatMessage(
|
||||
_ message: BitchatMessage,
|
||||
colorScheme: ColorScheme,
|
||||
nickname: String
|
||||
) -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
let isDark = colorScheme == .dark
|
||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
if message.sender == "system" {
|
||||
let content = AttributedString("* \(message.content) *")
|
||||
var contentStyle = AttributeContainer()
|
||||
contentStyle.foregroundColor = Color.gray
|
||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||
result.append(content.mergingAttributes(contentStyle))
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
} else {
|
||||
let sender = AttributedString("<@\(message.sender)> ")
|
||||
var senderStyle = AttributeContainer()
|
||||
senderStyle.foregroundColor = primaryColor
|
||||
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
||||
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
||||
result.append(sender.mergingAttributes(senderStyle))
|
||||
|
||||
// Process content to highlight mentions
|
||||
let contentText = message.content
|
||||
let pattern = "@([\\p{L}0-9_]+)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let nsContent = contentText as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
|
||||
var processedContent = AttributedString()
|
||||
var lastEndIndex = contentText.startIndex
|
||||
|
||||
for match in matches {
|
||||
if let range = Range(match.range(at: 0), in: contentText) {
|
||||
// Add text before mention
|
||||
if lastEndIndex < range.lowerBound {
|
||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the mention with highlight
|
||||
let mentionText = String(contentText[range])
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
mentionStyle.foregroundColor = Color.orange
|
||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||
|
||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if lastEndIndex < contentText.endIndex {
|
||||
let remainingText = String(contentText[lastEndIndex...])
|
||||
var normalStyle = AttributeContainer()
|
||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
||||
}
|
||||
|
||||
result.append(processedContent)
|
||||
|
||||
if message.isRelay, let originalSender = message.originalSender {
|
||||
let relay = AttributedString(" (via \(originalSender))")
|
||||
var relayStyle = AttributeContainer()
|
||||
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
||||
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
||||
result.append(relay.mergingAttributes(relayStyle))
|
||||
}
|
||||
|
||||
// Add timestamp
|
||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||
var timestampStyle = AttributeContainer()
|
||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func isSelfMessage(
|
||||
_ message: BitchatMessage,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> Bool {
|
||||
if let spid = message.senderPeerID?.id {
|
||||
// In geohash channels, compare against our per-geohash nostr short ID
|
||||
if case .location = activeChannel, spid.hasPrefix("nostr:"),
|
||||
let myGeo = myNostrPubkey {
|
||||
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
return spid == myPeerID
|
||||
}
|
||||
// Fallback by nickname
|
||||
if message.sender == nickname { return true }
|
||||
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func formatContent(
|
||||
_ content: String,
|
||||
nsContent: NSString,
|
||||
nsLen: Int,
|
||||
message: BitchatMessage,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isDark: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
// Extract all matches
|
||||
let hasMentionsHint = content.contains("@")
|
||||
let hasHashtagsHint = content.contains("#")
|
||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||
let hasCashuHint = content.lowercased().contains("cashu")
|
||||
|
||||
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||||
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||
|
||||
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||
|
||||
func overlapsMention(_ r: NSRange) -> Bool {
|
||||
for mr in mentionRanges {
|
||||
if NSIntersectionRange(r, mr).length > 0 { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attachedToMention(_ r: NSRange) -> Bool {
|
||||
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||||
var i = content.index(before: nsRange.lowerBound)
|
||||
while true {
|
||||
let ch = content[i]
|
||||
if ch.isWhitespace || ch.isNewline { break }
|
||||
if ch == "@" { return true }
|
||||
if i == content.startIndex { break }
|
||||
i = content.index(before: i)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||
guard let nsRange = Range(r, in: content) else { return false }
|
||||
if nsRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: nsRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}
|
||||
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "hashtag"))
|
||||
}
|
||||
for match in mentionMatches {
|
||||
allMatches.append((match.range(at: 0), "mention"))
|
||||
}
|
||||
for match in urlMatches where !overlapsMention(match.range) {
|
||||
allMatches.append((match.range, "url"))
|
||||
}
|
||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "cashu"))
|
||||
}
|
||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "lightning"))
|
||||
}
|
||||
|
||||
// Exclude overlaps with lightning/url for bolt11/lnurl
|
||||
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||
for or in occupied {
|
||||
if NSIntersectionRange(r, or).length > 0 { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "bolt11"))
|
||||
}
|
||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "lnurl"))
|
||||
}
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
// Build content with styling
|
||||
var processedContent = AttributedString()
|
||||
var lastEnd = content.startIndex
|
||||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||||
|
||||
for (range, type) in allMatches {
|
||||
if let nsRange = Range(range, in: content) {
|
||||
// Add text before match
|
||||
if lastEnd < nsRange.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
var beforeStyle = AttributeContainer()
|
||||
beforeStyle.foregroundColor = baseColor
|
||||
beforeStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
beforeStyle.font = beforeStyle.font?.bold()
|
||||
}
|
||||
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||
}
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
let matchText = String(content[nsRange])
|
||||
processedContent.append(formatMatch(
|
||||
matchText,
|
||||
type: type,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isDark: isDark,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
))
|
||||
|
||||
lastEnd = nsRange.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text after last match
|
||||
if lastEnd < content.endIndex {
|
||||
let remainingText = String(content[lastEnd...])
|
||||
var remainingStyle = AttributeContainer()
|
||||
remainingStyle.foregroundColor = baseColor
|
||||
remainingStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
if isMentioned {
|
||||
remainingStyle.font = remainingStyle.font?.bold()
|
||||
}
|
||||
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||
}
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
private func formatMatch(
|
||||
_ matchText: String,
|
||||
type: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
isDark: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
switch type {
|
||||
case "mention":
|
||||
return formatMention(
|
||||
matchText,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
nickname: nickname,
|
||||
myPeerID: myPeerID,
|
||||
myNostrPubkey: myNostrPubkey,
|
||||
activeChannel: activeChannel
|
||||
)
|
||||
case "hashtag":
|
||||
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
|
||||
case "url":
|
||||
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
|
||||
case "cashu", "bolt11", "lnurl", "lightning":
|
||||
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
|
||||
default:
|
||||
return AttributedString(matchText)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMention(
|
||||
_ matchText: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool,
|
||||
nickname: String,
|
||||
myPeerID: String,
|
||||
myNostrPubkey: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
// Split optional '#abcd' suffix and color suffix light grey
|
||||
let (mBase, mSuffix) = matchText.splitSuffix()
|
||||
|
||||
// Determine if this mention targets me
|
||||
let mySuffix: String? = {
|
||||
if case .location = activeChannel, let myGeo = myNostrPubkey {
|
||||
return String(myGeo.suffix(4))
|
||||
}
|
||||
return String(myPeerID.prefix(4))
|
||||
}()
|
||||
|
||||
let isMentionToMe: Bool = {
|
||||
if mBase == nickname {
|
||||
if let suf = mySuffix, !mSuffix.isEmpty {
|
||||
return mSuffix == "#\(suf)"
|
||||
}
|
||||
return mSuffix.isEmpty
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
var mentionStyle = AttributeContainer()
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
|
||||
|
||||
var result = AttributedString()
|
||||
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||
|
||||
if !mSuffix.isEmpty {
|
||||
var suffixStyle = mentionStyle
|
||||
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
|
||||
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func formatHashtag(
|
||||
_ matchText: String,
|
||||
isDark: Bool,
|
||||
baseColor: Color,
|
||||
activeChannel: ChannelID
|
||||
) -> AttributedString {
|
||||
var hashtagStyle = AttributeContainer()
|
||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||
|
||||
// Determine if this hashtag represents the active channel
|
||||
let isActiveChannel: Bool = {
|
||||
if matchText.count > 1 {
|
||||
let tag = String(matchText.dropFirst()) // Remove '#'
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return tag.lowercased() == "mesh"
|
||||
case .location(let ch):
|
||||
return tag.lowercased() == ch.geohash.lowercased()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
if isActiveChannel {
|
||||
// Highlight active channel hashtag in green
|
||||
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
} else {
|
||||
// Link to geohash if valid
|
||||
if matchText.count > 1 {
|
||||
let tag = String(matchText.dropFirst())
|
||||
if tag.count >= 2, tag.count <= 12,
|
||||
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
|
||||
if let url = URL(string: "bitchat://geohash/\(tag)") {
|
||||
hashtagStyle.link = url
|
||||
}
|
||||
}
|
||||
}
|
||||
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
|
||||
}
|
||||
|
||||
return AttributedString(matchText).mergingAttributes(hashtagStyle)
|
||||
}
|
||||
|
||||
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||
var urlStyle = AttributeContainer()
|
||||
if let url = URL(string: matchText) {
|
||||
urlStyle.link = url
|
||||
}
|
||||
urlStyle.foregroundColor = baseColor
|
||||
urlStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
urlStyle.underlineStyle = .single
|
||||
return AttributedString(matchText).mergingAttributes(urlStyle)
|
||||
}
|
||||
|
||||
private func formatPayment(
|
||||
_ matchText: String,
|
||||
type: String,
|
||||
baseColor: Color,
|
||||
isSelf: Bool
|
||||
) -> AttributedString {
|
||||
var paymentStyle = AttributeContainer()
|
||||
paymentStyle.foregroundColor = baseColor
|
||||
paymentStyle.font = isSelf
|
||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||
: .bitchatSystem(size: 14, design: .monospaced)
|
||||
|
||||
// Make payment tokens tappable
|
||||
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
|
||||
paymentStyle.link = url
|
||||
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||||
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
|
||||
paymentStyle.link = url
|
||||
}
|
||||
}
|
||||
|
||||
return AttributedString(matchText).mergingAttributes(paymentStyle)
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,6 @@ final class NetworkActivationService: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
deinit {
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
@@ -50,10 +50,11 @@ final class NostrTransport: Transport {
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
private static let noiseServiceLock = NSLock()
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
if let noiseService = Self.cachedNoiseService {
|
||||
return noiseService
|
||||
}
|
||||
Self.noiseServiceLock.lock()
|
||||
defer { Self.noiseServiceLock.unlock() }
|
||||
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
Self.cachedNoiseService = noiseService
|
||||
return noiseService
|
||||
|
||||
@@ -27,10 +27,6 @@ final class PrivateChatManager: ObservableObject {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
deinit {
|
||||
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ enum TransportConfig {
|
||||
static let uiProcessedNostrEventsCap: Int = 2000
|
||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||
|
||||
// UI rate limiters (token buckets)
|
||||
static let uiSenderRateBucketCapacity: Double = 5
|
||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||
static let uiContentRateBucketCapacity: Double = 3
|
||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||
|
||||
// UI sleeps/delays
|
||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||
static let uiStartupShortSleepNs: UInt64 = 200_000_000
|
||||
|
||||
@@ -47,16 +47,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up NotificationCenter observers
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clean up Combine subscriptions
|
||||
cancellables.removeAll()
|
||||
|
||||
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupSubscriptions() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+90
-142
@@ -19,7 +19,12 @@ import UIKit
|
||||
|
||||
// MARK: - Main Content View
|
||||
|
||||
@MainActor
|
||||
struct ContentView: View {
|
||||
private struct ScrollRequest: Equatable {
|
||||
let id = UUID()
|
||||
let target: String
|
||||
}
|
||||
// MARK: - Properties
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@@ -46,7 +51,8 @@ struct ContentView: View {
|
||||
@State private var isAtBottomPublic: Bool = true
|
||||
@State private var isAtBottomPrivate: Bool = true
|
||||
@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 showLocationChannelsSheet = false
|
||||
@State private var showVerifySheet = false
|
||||
@@ -80,19 +86,50 @@ struct ContentView: View {
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Base layer - Main public chat (always visible)
|
||||
mainChatView
|
||||
VStack(spacing: 0) {
|
||||
mainHeaderView
|
||||
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||
.onChange(of: colorScheme) { newValue in
|
||||
viewModel.currentColorScheme = newValue
|
||||
}
|
||||
|
||||
// Private chat slide-over
|
||||
Divider()
|
||||
|
||||
// Messages area with overlays
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Base layer - public messages
|
||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||
.background(backgroundColor)
|
||||
|
||||
// Private chat slide-over (full screen with own header/input)
|
||||
if viewModel.selectedPrivateChatPeer != nil {
|
||||
privateChatView
|
||||
.frame(width: geometry.size.width)
|
||||
@@ -130,7 +167,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
||||
if !showSidebar {
|
||||
if !showSidebar && viewModel.selectedPrivateChatPeer == nil {
|
||||
HStack {
|
||||
Spacer()
|
||||
Color.clear
|
||||
@@ -142,15 +179,9 @@ struct ContentView: View {
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
if translationWidth < 0 {
|
||||
let newOffset = max(translationWidth, -300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||
@@ -168,7 +199,8 @@ struct ContentView: View {
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
|
||||
// Sidebar overlay
|
||||
// Sidebar overlay (only over messages area, not input)
|
||||
if viewModel.selectedPrivateChatPeer == nil {
|
||||
HStack(spacing: 0) {
|
||||
// Tap to dismiss area
|
||||
Color.clear
|
||||
@@ -195,6 +227,17 @@ struct ContentView: View {
|
||||
}())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Input always accessible below sidebar (only in public chat)
|
||||
if viewModel.selectedPrivateChatPeer == nil {
|
||||
inputView
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
#endif
|
||||
@@ -283,8 +326,8 @@ struct ContentView: View {
|
||||
Text(viewModel.bluetoothAlertMessage)
|
||||
}
|
||||
.onDisappear {
|
||||
// Clean up timers
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTask?.cancel()
|
||||
scrollThrottleTask = nil
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -409,6 +452,11 @@ struct ContentView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.onChange(of: pendingScrollRequest) { request in
|
||||
guard let request else { return }
|
||||
proxy.scrollTo(request.target, anchor: .bottom)
|
||||
pendingScrollRequest = nil
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
@@ -530,44 +578,27 @@ struct ContentView: View {
|
||||
let lastMsg = viewModel.messages.last!
|
||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||
if !isFromSelf {
|
||||
// Only autoscroll when user is at/near bottom
|
||||
guard isAtBottom.wrappedValue else { return }
|
||||
} else {
|
||||
// Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom.wrappedValue = true
|
||||
}
|
||||
// Throttle scroll animations to prevent excessive UI updates
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
||||
// Immediate scroll if enough time has passed
|
||||
let contextKey: String = {
|
||||
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
|
||||
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) }
|
||||
}
|
||||
enqueueScroll(target: target)
|
||||
} else {
|
||||
// Schedule a delayed scroll
|
||||
scrollThrottleTimer?.invalidate()
|
||||
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) }
|
||||
}
|
||||
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||
scheduleScroll(target: target, delay: remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,26 +616,18 @@ struct ContentView: View {
|
||||
} else {
|
||||
isAtBottom.wrappedValue = true
|
||||
}
|
||||
// Same throttling for private chats
|
||||
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
|
||||
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) }
|
||||
}
|
||||
enqueueScroll(target: target)
|
||||
} else {
|
||||
scrollThrottleTimer?.invalidate()
|
||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
||||
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) }
|
||||
}
|
||||
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||
scheduleScroll(target: target, delay: remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,10 +801,12 @@ struct ContentView: View {
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
@@ -951,7 +976,7 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.simultaneousGesture(
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
@@ -962,15 +987,9 @@ struct ContentView: View {
|
||||
|
||||
if translationWidth > 0 {
|
||||
let newOffset = min(translationWidth, 300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
@@ -999,77 +1018,6 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - View Components
|
||||
|
||||
private var mainChatView: some View {
|
||||
VStack(spacing: 0) {
|
||||
mainHeaderView
|
||||
Divider()
|
||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||
Divider()
|
||||
inputView
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.foregroundColor(textColor)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
|
||||
// Only handle if drag is predominantly horizontal (prevents interfering with scroll)
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
|
||||
|
||||
if !showSidebar && translationWidth < 0 {
|
||||
let newOffset = max(translationWidth, -300)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
} else if showSidebar && translationWidth > 0 {
|
||||
let newOffset = min(-300 + translationWidth, 0)
|
||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
sidebarDragOffset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||
|
||||
// Only handle if drag is predominantly horizontal
|
||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
|
||||
sidebarDragOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
if !showSidebar {
|
||||
if translationWidth < -100 || (translationWidth < -50 && velocity < -500) {
|
||||
showSidebar = true
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
} else {
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var privateChatView: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Vertical separator bar
|
||||
|
||||
@@ -12,7 +12,7 @@ import CryptoKit
|
||||
|
||||
final class IntegrationTests: XCTestCase {
|
||||
|
||||
var nodes: [String: MockBluetoothMeshService] = [:]
|
||||
var nodes: [String: MockBLEService] = [:]
|
||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||
private var mockKeychain: MockKeychain!
|
||||
|
||||
@@ -588,7 +588,7 @@ final class IntegrationTests: XCTestCase {
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createNode(_ name: String, peerID: PeerID) {
|
||||
let node = MockBluetoothMeshService()
|
||||
let node = MockBLEService()
|
||||
node.myPeerID = peerID
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// MockBluetoothMeshService.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
@testable import bitchat
|
||||
|
||||
// Compatibility wrapper for old tests - please use MockBLEService directly
|
||||
typealias MockBluetoothMeshService = MockBLEService
|
||||
Reference in New Issue
Block a user