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",
|
name: "bitchat",
|
||||||
defaultLocalization: "en",
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
|||||||
+20
-19
@@ -49,9 +49,10 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
let nickname = chatViewModel.nickname
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
@@ -237,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor [weak self] in
|
||||||
if identifier.hasPrefix("private-") {
|
guard let self = self else {
|
||||||
// Get peer ID from userInfo
|
completionHandler([.banner, .sound])
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
return
|
||||||
// Don't show notification if the private chat is already open
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// Check if this is a private message notification and chat already open
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
if identifier.hasPrefix("private-"),
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
let peerID = userInfo["peerID"] as? String,
|
||||||
let deep = userInfo["deeplink"] as? String,
|
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
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,
|
||||||
|
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
|
ch.geohash == gh {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
extension Array where Element == BitchatMessage {
|
||||||
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
func cleanedAndDeduped() -> [Element] {
|
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
|
|
||||||
}
|
|
||||||
@@ -109,20 +109,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
|
|||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: PeerID)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
@@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
|
@MainActor
|
||||||
extension BitchatDelegate {
|
extension BitchatDelegate {
|
||||||
func isFavorite(fingerprint: String) -> Bool {
|
func isFavorite(fingerprint: String) -> Bool {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+194
-192
@@ -406,23 +406,35 @@ final class BLEService: NSObject {
|
|||||||
// MARK: Lifecycle
|
// MARK: Lifecycle
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
// Start BLE services if not already running
|
performOnBLEQueue { service in
|
||||||
if centralManager?.state == .poweredOn {
|
// Start BLE services if not already running
|
||||||
centralManager?.scanForPeripherals(
|
if service.centralManager?.state == .poweredOn {
|
||||||
withServices: [BLEService.serviceUUID],
|
service.centralManager?.scanForPeripherals(
|
||||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
withServices: [BLEService.serviceUUID],
|
||||||
)
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial announce after services are ready
|
// Send initial announce after services are ready.
|
||||||
// Use longer delay to avoid conflicts with other announces
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
// Send leave message synchronously to ensure delivery
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
stopServicesOnBLEQueue()
|
||||||
|
} else {
|
||||||
|
bleQueue.sync {
|
||||||
|
self.stopServicesOnBLEQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopServicesOnBLEQueue() {
|
||||||
|
assertOnBLEQueue()
|
||||||
|
// Send a final leave message to connected peers before shutting down.
|
||||||
let leavePacket = BitchatPacket(
|
let leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -432,43 +444,45 @@ final class BLEService: NSObject {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send immediately to all connected peers
|
|
||||||
if let data = leavePacket.toBinaryData(padding: false) {
|
if let data = leavePacket.toBinaryData(padding: false) {
|
||||||
// Send to peripherals we're connected to as central
|
|
||||||
for state in peripherals.values where state.isConnected {
|
for state in peripherals.values where state.isConnected {
|
||||||
if let characteristic = state.characteristic {
|
if let characteristic = state.characteristic {
|
||||||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !subscribedCentrals.isEmpty, let characteristic = characteristic {
|
||||||
// Send to centrals subscribed to us as peripheral
|
_ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
|
||||||
if subscribedCentrals.count > 0 && characteristic != nil {
|
|
||||||
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give leave message a moment to send
|
let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds
|
||||||
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
if delay > 0 {
|
||||||
|
Thread.sleep(forTimeInterval: delay)
|
||||||
// Clear pending notifications
|
}
|
||||||
|
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingNotifications.removeAll()
|
pendingNotifications.removeAll()
|
||||||
|
pendingPeripheralWrites.removeAll()
|
||||||
|
pendingDirectedRelays.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop timer
|
|
||||||
maintenanceTimer?.cancel()
|
maintenanceTimer?.cancel()
|
||||||
maintenanceTimer = nil
|
maintenanceTimer = nil
|
||||||
scanDutyTimer?.cancel()
|
scanDutyTimer?.cancel()
|
||||||
scanDutyTimer = nil
|
scanDutyTimer = nil
|
||||||
|
|
||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
peripheralManager?.stopAdvertising()
|
peripheralManager?.stopAdvertising()
|
||||||
|
|
||||||
// Disconnect all peripherals
|
|
||||||
for state in peripherals.values {
|
for state in peripherals.values {
|
||||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func emergencyDisconnectAll() {
|
func emergencyDisconnectAll() {
|
||||||
@@ -483,12 +497,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Clear processed messages
|
// Clear processed messages
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
|
|
||||||
// Clear peripheral references
|
|
||||||
peripherals.removeAll()
|
|
||||||
peerToPeripheralUUID.removeAll()
|
|
||||||
subscribedCentrals.removeAll()
|
|
||||||
centralToPeerID.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Connectivity and peers
|
// MARK: Connectivity and peers
|
||||||
@@ -702,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate {
|
|||||||
extension BLEService: CBCentralManagerDelegate {
|
extension BLEService: CBCentralManagerDelegate {
|
||||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
// Notify delegate about state change on main thread
|
// Notify delegate about state change on main thread
|
||||||
Task { @MainActor in
|
let state = central.state
|
||||||
self.delegate?.didUpdateBluetoothState(central.state)
|
notifyUI { service in
|
||||||
|
service.delegate?.didUpdateBluetoothState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
if central.state == .poweredOn {
|
if central.state == .poweredOn {
|
||||||
@@ -933,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||||
|
|
||||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
|
||||||
// Get current peer list (after removal)
|
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
if let peerID {
|
if let peerID {
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
service.notifyPeerDisconnectedDebounced(peerID)
|
||||||
}
|
}
|
||||||
self.requestPeerDataPublish()
|
service.requestPeerDataPublish()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
centralToPeerID.removeValue(forKey: centralUUID)
|
centralToPeerID.removeValue(forKey: centralUUID)
|
||||||
|
|
||||||
// Update UI immediately
|
// Update UI immediately
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
service.notifyPeerDisconnectedDebounced(peerID)
|
||||||
// Get current peer list (after removal)
|
service.requestPeerDataPublish()
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
|
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
|
||||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
|
||||||
self.requestPeerDataPublish()
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1517,13 +1517,29 @@ extension BLEService {
|
|||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Hop to the MainActor and hand `self` to the caller for UI delegate updates.
|
||||||
private func notifyUI(_ block: @escaping () -> Void) {
|
private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) {
|
||||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
Task { @MainActor [weak self] in
|
||||||
Task { @MainActor in
|
guard let self = self else { return }
|
||||||
block()
|
block(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure work that touches CoreBluetooth objects executes on `bleQueue`
|
||||||
|
private func performOnBLEQueue(_ work: @escaping (BLEService) -> Void) {
|
||||||
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
work(self)
|
||||||
|
} else {
|
||||||
|
bleQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
work(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertOnBLEQueue(file: StaticString = #file, line: UInt = #line) {
|
||||||
|
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "BLE queue required", file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
@@ -1634,42 +1650,13 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||||
// BLE operations run on bleQueue; keep queue affinity
|
performOnBLEQueue { service in
|
||||||
bleQueue.async { [weak self] in
|
service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic)
|
||||||
guard let self = self else { return }
|
|
||||||
let uuid = peripheral.identifier.uuidString
|
|
||||||
if peripheral.canSendWriteWithoutResponse {
|
|
||||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
|
||||||
} else {
|
|
||||||
self.collectionsQueue.async(flags: .barrier) {
|
|
||||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
|
||||||
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
|
||||||
let newSize = data.count
|
|
||||||
// If single chunk exceeds cap, drop it immediately
|
|
||||||
if newSize > capBytes {
|
|
||||||
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
|
||||||
} else {
|
|
||||||
// Append and trim from the front to respect cap
|
|
||||||
var total = queue.reduce(0) { $0 + $1.count }
|
|
||||||
queue.append(data)
|
|
||||||
total += newSize
|
|
||||||
if total > capBytes {
|
|
||||||
var removedBytes = 0
|
|
||||||
while total > capBytes && !queue.isEmpty {
|
|
||||||
let removed = queue.removeFirst()
|
|
||||||
removedBytes += removed.count
|
|
||||||
total -= removed.count
|
|
||||||
}
|
|
||||||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
|
||||||
}
|
|
||||||
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||||||
|
assertOnBLEQueue()
|
||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -1701,6 +1688,38 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func writeOrEnqueueOnBLEQueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
|
||||||
|
let uuid = peripheral.identifier.uuidString
|
||||||
|
if peripheral.canSendWriteWithoutResponse {
|
||||||
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionsQueue.async(flags: .barrier) {
|
||||||
|
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||||
|
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
let newSize = data.count
|
||||||
|
if newSize > capBytes {
|
||||||
|
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = queue.reduce(0) { $0 + $1.count }
|
||||||
|
queue.append(data)
|
||||||
|
total += newSize
|
||||||
|
if total > capBytes {
|
||||||
|
var removedBytes = 0
|
||||||
|
while total > capBytes && !queue.isEmpty {
|
||||||
|
let removed = queue.removeFirst()
|
||||||
|
removedBytes += removed.count
|
||||||
|
total -= removed.count
|
||||||
|
}
|
||||||
|
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
|
||||||
|
}
|
||||||
|
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Application State Handlers (iOS)
|
// MARK: Application State Handlers (iOS)
|
||||||
|
|
||||||
@@ -1784,8 +1803,8 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify delegate that message was sent
|
// Notify delegate that message was sent
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to encrypt message: \(error)")
|
SecureLogger.error("Failed to encrypt message: \(error)")
|
||||||
@@ -1805,8 +1824,8 @@ extension BLEService {
|
|||||||
initiateNoiseHandshake(with: recipientID)
|
initiateNoiseHandshake(with: recipientID)
|
||||||
|
|
||||||
// Notify delegate that message is pending
|
// Notify delegate that message is pending
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1882,8 +1901,8 @@ extension BLEService {
|
|||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
|
|
||||||
// Notify delegate that message was sent
|
// Notify delegate that message was sent
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
|
||||||
@@ -1891,8 +1910,8 @@ extension BLEService {
|
|||||||
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
||||||
|
|
||||||
// Notify delegate of failure
|
// Notify delegate of failure
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1907,11 +1926,13 @@ extension BLEService {
|
|||||||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
performOnBLEQueue { service in
|
||||||
sendEncrypted(packet, data: data, pad: padForBLE)
|
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||||
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)
|
// MARK: Broadcast helpers (single responsibility)
|
||||||
@@ -2550,21 +2571,15 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify UI on main thread
|
// Notify UI on main thread
|
||||||
notifyUI { [weak self] in
|
let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer)
|
||||||
guard let self = self else { return }
|
notifyUI { service in
|
||||||
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
// Get current peer list (after addition)
|
if isDirectAnnounce {
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
service.delegate?.didConnectToPeer(peerID)
|
||||||
|
service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
|
||||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
|
||||||
self.delegate?.didConnectToPeer(peerID)
|
|
||||||
// Schedule initial unicast sync to this peer
|
|
||||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
|
||||||
}
|
}
|
||||||
|
service.requestPeerDataPublish()
|
||||||
self.requestPeerDataPublish()
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track for sync (include our own and others' announces)
|
// Track for sync (include our own and others' announces)
|
||||||
@@ -2636,17 +2651,26 @@ extension BLEService {
|
|||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
accepted = true
|
accepted = true
|
||||||
senderNickname = myNickname
|
senderNickname = myNickname
|
||||||
}
|
|
||||||
else if let info = peers[peerID], info.isVerifiedNickname {
|
|
||||||
// Known verified peer path
|
|
||||||
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)
|
|
||||||
if hasCollision {
|
|
||||||
senderNickname += "#" + String(peerID.id.prefix(4))
|
|
||||||
}
|
|
||||||
} else {
|
} 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
|
||||||
|
}
|
||||||
|
return (info, collision)
|
||||||
|
}
|
||||||
|
if let info = peerLookup.0, info.isVerifiedNickname {
|
||||||
|
accepted = true
|
||||||
|
senderNickname = info.nickname
|
||||||
|
let hasCollision = peerLookup.1 || (selfNickname == info.nickname)
|
||||||
|
if hasCollision {
|
||||||
|
senderNickname += "#" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !accepted {
|
||||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||||
// Find candidate identities by peerID prefix (16 hex)
|
// Find candidate identities by peerID prefix (16 hex)
|
||||||
@@ -2704,8 +2728,8 @@ extension BLEService {
|
|||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2770,28 +2794,28 @@ extension BLEService {
|
|||||||
switch NoisePayloadType(rawValue: payloadType) {
|
switch NoisePayloadType(rawValue: payloadType) {
|
||||||
case .privateMessage:
|
case .privateMessage:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .delivered:
|
case .delivered:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||||
@@ -2816,14 +2840,10 @@ extension BLEService {
|
|||||||
// Remove any stored announcement for sync purposes
|
// Remove any stored announcement for sync purposes
|
||||||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||||||
// Send on main thread
|
// Send on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self = self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
service.delegate?.didDisconnectFromPeer(peerID)
|
||||||
// Get current peer list (after removal)
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
self.delegate?.didDisconnectFromPeer(peerID)
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2841,62 +2861,48 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
performOnBLEQueue { service in
|
||||||
let now = Date()
|
service.sendAnnounceOnBLEQueue(forceSend: forceSend)
|
||||||
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
|
|
||||||
|
|
||||||
// Even forced sends should respect a minimum interval to avoid overwhelming BLE
|
|
||||||
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
|
||||||
|
|
||||||
if timeSinceLastAnnounce < minInterval {
|
|
||||||
// Skipping announce (rate limited)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAnnounceOnBLEQueue(forceSend: Bool) {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastAnnounceSent)
|
||||||
|
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
|
||||||
|
guard elapsed >= minInterval else { return }
|
||||||
lastAnnounceSent = now
|
lastAnnounceSent = now
|
||||||
|
|
||||||
// Reduced logging - only log errors, not every announce
|
let noisePub = noiseService.getStaticPublicKeyData()
|
||||||
|
let signingPub = noiseService.getSigningPublicKeyData()
|
||||||
// Create announce payload with both noise and signing public keys
|
|
||||||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
|
||||||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
|
||||||
|
|
||||||
let announcement = AnnouncementPacket(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub
|
signingPublicKey: signingPub
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
|
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create packet with signature using the noise private key
|
let unsigned = BitchatPacket(
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil, // Will be set by signPacket below
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sign the packet using the noise private key
|
guard let signedPacket = noiseService.signPacket(unsigned) else {
|
||||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
|
||||||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
broadcastPacket(signedPacket)
|
||||||
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)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2938,6 +2944,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||||
|
@MainActor
|
||||||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let last = recentDisconnectNotifies[peerID]
|
let last = recentDisconnectNotifies[peerID]
|
||||||
@@ -3082,18 +3089,13 @@ extension BLEService {
|
|||||||
|
|
||||||
// Update UI if there were direct disconnections or offline removals
|
// Update UI if there were direct disconnections or offline removals
|
||||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||||
notifyUI { [weak self] in
|
notifyUI { service in
|
||||||
guard let self else { return }
|
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
|
||||||
|
|
||||||
// Get current peer list (after removal)
|
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
|
|
||||||
|
|
||||||
for peerID in disconnectedPeers {
|
for peerID in disconnectedPeers {
|
||||||
self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
service.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
service.requestPeerDataPublish()
|
||||||
self.requestPeerDataPublish()
|
service.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,13 +45,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
.assign(to: &$mutualFavorites)
|
.assign(to: &$mutualFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add or update a favorite
|
/// Add or update a favorite
|
||||||
func addFavorite(
|
func addFavorite(
|
||||||
peerNoisePublicKey: Data,
|
peerNoisePublicKey: Data,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
@@ -15,8 +16,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
private var membership: Set<String> = []
|
private var membership: Set<String> = []
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder = CLGeocoder()
|
||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
|
#endif
|
||||||
|
|
||||||
private let storage: UserDefaults
|
private let storage: UserDefaults
|
||||||
|
|
||||||
@@ -25,12 +28,6 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Cancel any pending geocoding operations
|
|
||||||
geocoder.cancelGeocode()
|
|
||||||
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
return membership.contains(Self.normalize(geohash))
|
return membership.contains(Self.normalize(geohash))
|
||||||
@@ -121,6 +118,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
let gh = Self.normalize(geohash)
|
let gh = Self.normalize(geohash)
|
||||||
guard !gh.isEmpty else { return }
|
guard !gh.isEmpty else { return }
|
||||||
if bookmarkNames[gh] != nil { return }
|
if bookmarkNames[gh] != nil { return }
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
if resolving.contains(gh) { return }
|
if resolving.contains(gh) { return }
|
||||||
resolving.insert(gh)
|
resolving.insert(gh)
|
||||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
// 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]) {
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
var uniqueAdmins = OrderedSet<String>()
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
var idx = 0
|
var idx = 0
|
||||||
@@ -215,6 +215,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Testing-only reset helper
|
/// 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
|
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) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
|||||||
@@ -101,12 +101,6 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscribe()
|
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) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
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() {}
|
private init() {}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ final class NostrTransport: Transport {
|
|||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
|
private static let noiseServiceLock = NSLock()
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
if let noiseService = Self.cachedNoiseService {
|
Self.noiseServiceLock.lock()
|
||||||
return noiseService
|
defer { Self.noiseServiceLock.unlock() }
|
||||||
}
|
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
Self.cachedNoiseService = noiseService
|
Self.cachedNoiseService = noiseService
|
||||||
return noiseService
|
return noiseService
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ enum TransportConfig {
|
|||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
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
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -46,17 +46,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
updatePeers()
|
updatePeers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Clean up NotificationCenter observers
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clean up Combine subscriptions
|
|
||||||
cancellables.removeAll()
|
|
||||||
|
|
||||||
SecureLogger.debug("UnifiedPeerService deinitialized", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubscriptions() {
|
private func setupSubscriptions() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+181
-233
@@ -19,7 +19,12 @@ import UIKit
|
|||||||
|
|
||||||
// MARK: - Main Content View
|
// MARK: - Main Content View
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
private struct ScrollRequest: Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
let target: String
|
||||||
|
}
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@@ -46,7 +51,8 @@ struct ContentView: View {
|
|||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@State private var lastScrollTime: Date = .distantPast
|
@State private var lastScrollTime: Date = .distantPast
|
||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTask: Task<Void, Never>?
|
||||||
|
@State private var pendingScrollRequest: ScrollRequest?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
@State private var showVerifySheet = false
|
@State private var showVerifySheet = false
|
||||||
@@ -79,122 +85,159 @@ struct ContentView: View {
|
|||||||
private var headerLineLimit: Int? {
|
private var headerLineLimit: Int? {
|
||||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func enqueueScroll(target: String) {
|
||||||
|
pendingScrollRequest = ScrollRequest(target: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleScroll(target: String, delay: TimeInterval) {
|
||||||
|
scrollThrottleTask?.cancel()
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
if delay <= 0 {
|
||||||
|
lastScrollTime = Date()
|
||||||
|
enqueueScroll(target: target)
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nanoseconds = UInt64(max(delay, 0) * 1_000_000_000)
|
||||||
|
scrollThrottleTask = Task { [target] in
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
await MainActor.run {
|
||||||
|
lastScrollTime = Date()
|
||||||
|
enqueueScroll(target: target)
|
||||||
|
scrollThrottleTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
VStack(spacing: 0) {
|
||||||
ZStack {
|
mainHeaderView
|
||||||
// Base layer - Main public chat (always visible)
|
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||||
mainChatView
|
.onChange(of: colorScheme) { newValue in
|
||||||
.onAppear { viewModel.currentColorScheme = colorScheme }
|
viewModel.currentColorScheme = newValue
|
||||||
.onChange(of: colorScheme) { newValue in
|
|
||||||
viewModel.currentColorScheme = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private chat slide-over
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
|
||||||
privateChatView
|
|
||||||
.frame(width: geometry.size.width)
|
|
||||||
.background(backgroundColor)
|
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .move(edge: .trailing),
|
|
||||||
removal: .move(edge: .trailing)
|
|
||||||
))
|
|
||||||
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
|
|
||||||
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
|
|
||||||
.gesture(
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if value.translation.width > 0 && !value.translation.width.isNaN {
|
|
||||||
let maxWidth = max(0, geometry.size.width)
|
|
||||||
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showPrivateChat = false
|
|
||||||
backSwipeOffset = 0
|
|
||||||
viewModel.endPrivateChat()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
|
||||||
backSwipeOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
Divider()
|
||||||
if !showSidebar {
|
|
||||||
HStack {
|
// Messages area with overlays
|
||||||
Spacer()
|
GeometryReader { geometry in
|
||||||
Color.clear
|
ZStack {
|
||||||
.frame(width: 20)
|
// Base layer - public messages
|
||||||
.contentShape(Rectangle())
|
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)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .trailing),
|
||||||
|
removal: .move(edge: .trailing)
|
||||||
|
))
|
||||||
|
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
|
||||||
|
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 5)
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
if value.translation.width > 0 && !value.translation.width.isNaN {
|
||||||
if translationWidth < 0 {
|
let maxWidth = max(0, geometry.size.width)
|
||||||
let newOffset = max(translationWidth, -300)
|
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
||||||
if translationWidth < -50 || velocity < -300 {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showPrivateChat = false
|
||||||
sidebarDragOffset = 0
|
backSwipeOffset = 0
|
||||||
} else {
|
viewModel.endPrivateChat()
|
||||||
sidebarDragOffset = 0
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
||||||
|
backSwipeOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.allowsHitTesting(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar overlay
|
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
||||||
HStack(spacing: 0) {
|
if !showSidebar && viewModel.selectedPrivateChatPeer == nil {
|
||||||
// Tap to dismiss area
|
HStack {
|
||||||
Color.clear
|
Spacer()
|
||||||
.contentShape(Rectangle())
|
Color.clear
|
||||||
.onTapGesture {
|
.frame(width: 20)
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
.contentShape(Rectangle())
|
||||||
showSidebar = false
|
.gesture(
|
||||||
sidebarDragOffset = 0
|
DragGesture(minimumDistance: 5)
|
||||||
}
|
.onChanged { value in
|
||||||
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
|
if translationWidth < 0 {
|
||||||
|
let newOffset = max(translationWidth, -300)
|
||||||
|
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
|
||||||
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
if translationWidth < -50 || velocity < -300 {
|
||||||
|
showSidebar = true
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(true)
|
||||||
// Always render sidebar to avoid layout recalculation during drag
|
}
|
||||||
sidebarView
|
|
||||||
#if os(macOS)
|
// Sidebar overlay (only over messages area, not input)
|
||||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
if viewModel.selectedPrivateChatPeer == nil {
|
||||||
#else
|
HStack(spacing: 0) {
|
||||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
// Tap to dismiss area
|
||||||
#endif
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always render sidebar to avoid layout recalculation during drag
|
||||||
|
sidebarView
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||||
|
#else
|
||||||
|
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.offset(x: {
|
||||||
|
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
||||||
|
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
||||||
|
return showSidebar ? dragOffset : width + dragOffset
|
||||||
|
}())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.offset(x: {
|
}
|
||||||
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
|
||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
Divider()
|
||||||
return showSidebar ? dragOffset : width + dragOffset
|
|
||||||
}())
|
// Input always accessible below sidebar (only in public chat)
|
||||||
|
if viewModel.selectedPrivateChatPeer == nil {
|
||||||
|
inputView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
@@ -283,8 +326,8 @@ struct ContentView: View {
|
|||||||
Text(viewModel.bluetoothAlertMessage)
|
Text(viewModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Clean up timers
|
scrollThrottleTask?.cancel()
|
||||||
scrollThrottleTimer?.invalidate()
|
scrollThrottleTask = nil
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,6 +452,11 @@ struct ContentView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
.onChange(of: pendingScrollRequest) { request in
|
||||||
|
guard let request else { return }
|
||||||
|
proxy.scrollTo(request.target, anchor: .bottom)
|
||||||
|
pendingScrollRequest = nil
|
||||||
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
guard url.scheme == "bitchat", url.host == "user" else { return }
|
guard url.scheme == "bitchat", url.host == "user" else { return }
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
@@ -530,44 +578,27 @@ struct ContentView: View {
|
|||||||
let lastMsg = viewModel.messages.last!
|
let lastMsg = viewModel.messages.last!
|
||||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
if !isFromSelf {
|
if !isFromSelf {
|
||||||
// Only autoscroll when user is at/near bottom
|
|
||||||
guard isAtBottom.wrappedValue else { return }
|
guard isAtBottom.wrappedValue else { return }
|
||||||
} else {
|
} else {
|
||||||
// Ensure we consider ourselves at bottom for subsequent messages
|
|
||||||
isAtBottom.wrappedValue = true
|
isAtBottom.wrappedValue = true
|
||||||
}
|
}
|
||||||
// Throttle scroll animations to prevent excessive UI updates
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
let contextKey: String = {
|
||||||
// Immediate scroll if enough time has passed
|
switch locationManager.selectedChannel {
|
||||||
lastScrollTime = now
|
case .mesh: return "mesh"
|
||||||
let contextKey: String = {
|
case .location(let ch): return "geo:\(ch.geohash)"
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let count = windowCountPublic
|
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
}
|
||||||
} else {
|
}()
|
||||||
// Schedule a delayed scroll
|
let count = windowCountPublic
|
||||||
scrollThrottleTimer?.invalidate()
|
if let target = viewModel.messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||||
lastScrollTime = Date()
|
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||||
let contextKey: String = {
|
scrollThrottleTask?.cancel()
|
||||||
switch locationManager.selectedChannel {
|
lastScrollTime = now
|
||||||
case .mesh: return "mesh"
|
enqueueScroll(target: target)
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
} else {
|
||||||
}
|
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||||
}()
|
scheduleScroll(target: target, delay: remaining)
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,26 +616,18 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
isAtBottom.wrappedValue = true
|
isAtBottom.wrappedValue = true
|
||||||
}
|
}
|
||||||
// Same throttling for private chats
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
let contextKey = "dm:\(peerID)"
|
||||||
lastScrollTime = now
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
let contextKey = "dm:\(peerID)"
|
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
let elapsed = now.timeIntervalSince(lastScrollTime)
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
if elapsed > TransportConfig.uiScrollThrottleSeconds {
|
||||||
DispatchQueue.main.async {
|
scrollThrottleTask?.cancel()
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
lastScrollTime = now
|
||||||
}
|
enqueueScroll(target: target)
|
||||||
} else {
|
} else {
|
||||||
scrollThrottleTimer?.invalidate()
|
let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
scheduleScroll(target: target, delay: remaining)
|
||||||
lastScrollTime = Date()
|
|
||||||
let contextKey = "dm:\(peerID)"
|
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -682,9 +705,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||||
@@ -778,9 +801,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||||
// Get cursor position (approximate - end of text for now)
|
Task { @MainActor in
|
||||||
let cursorPosition = newValue.count
|
// Get cursor position (approximate - end of text for now)
|
||||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
let cursorPosition = newValue.count
|
||||||
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for command autocomplete (instant, no debounce needed)
|
// Check for command autocomplete (instant, no debounce needed)
|
||||||
@@ -951,7 +976,7 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.simultaneousGesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 10)
|
DragGesture(minimumDistance: 10)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
@@ -962,13 +987,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
if translationWidth > 0 {
|
if translationWidth > 0 {
|
||||||
let newOffset = min(translationWidth, 300)
|
let newOffset = min(translationWidth, 300)
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
sidebarDragOffset = newOffset
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
@@ -998,78 +1017,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// 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 {
|
private var privateChatView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Vertical separator bar
|
// Vertical separator bar
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import CryptoKit
|
|||||||
|
|
||||||
final class IntegrationTests: XCTestCase {
|
final class IntegrationTests: XCTestCase {
|
||||||
|
|
||||||
var nodes: [String: MockBluetoothMeshService] = [:]
|
var nodes: [String: MockBLEService] = [:]
|
||||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
private var mockKeychain: MockKeychain!
|
private var mockKeychain: MockKeychain!
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: PeerID) {
|
private func createNode(_ name: String, peerID: PeerID) {
|
||||||
let node = MockBluetoothMeshService()
|
let node = MockBLEService()
|
||||||
node.myPeerID = peerID
|
node.myPeerID = peerID
|
||||||
node.mockNickname = name
|
node.mockNickname = name
|
||||||
nodes[name] = node
|
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