diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index bbe13235..519b340b 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -69,7 +69,6 @@ final class NostrRelayManager: ObservableObject { private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() private let encoder = JSONEncoder() - private let decoder = JSONDecoder() private var networkService: NetworkActivationService { NetworkActivationService.shared } private var shouldUseTor: Bool { networkService.userTorEnabled } @@ -79,8 +78,6 @@ final class NostrRelayManager: ObservableObject { private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts - // Reconnection timer - private var reconnectionTimer: Timer? // Bump generation to invalidate scheduled reconnects when we reset/disconnect private var connectionGeneration: Int = 0 diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3bb1abf5..cf3cf67d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -138,11 +138,6 @@ final class BLEService: NSObject { private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:] // Noise typed payloads (ACKs, read receipts, etc.) pending handshake private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:] - // Keep a tiny buffer of the last few unique announces we've seen (by sender) - private var recentAnnounceBySender: [PeerID: BitchatPacket] = [:] - private var recentAnnounceOrder: [PeerID] = [] - private let recentAnnounceBufferCap = 3 - // Queue for notifications that failed due to full queue private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] @@ -354,8 +349,6 @@ final class BLEService: NSObject { } collectionsQueue.sync(flags: .barrier) { - recentAnnounceBySender.removeAll() - recentAnnounceOrder.removeAll() pendingPeripheralWrites.removeAll() pendingFragmentTransfers.removeAll() pendingNotifications.removeAll() @@ -1053,34 +1046,6 @@ final class BLEService: NSObject { } } - private func rebroadcastRecentAnnounces() { - // Snapshot sender order to preserve ordering and avoid holding locks while sending - let packets: [BitchatPacket] = collectionsQueue.sync { - recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] } - } - guard !packets.isEmpty else { return } - for (idx, pkt) in packets.enumerated() { - // Stagger slightly to avoid bursts - let delayMs = idx * 20 - messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in - self?.broadcastPacket(pkt) - } - } - } - - private func sendData(_ data: Data, to peripheral: CBPeripheral) { - // Fire-and-forget: Simple send without complex fallback logic - guard peripheral.state == .connected else { return } - - let peripheralUUID = peripheral.identifier.uuidString - guard let state = peripherals[peripheralUUID], - let characteristic = state.characteristic else { return } - - // Fire-and-forget principle: always use .withoutResponse for speed - // CoreBluetooth will handle fragmentation at L2CAP layer - writeOrEnqueue(data, to: peripheral, characteristic: characteristic, priority: .high) - } - private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { if peerID == myPeerID && packet.ttl != 0 { return } @@ -1474,17 +1439,6 @@ final class BLEService: NSObject { } } - private func sendLeave() { - SecureLogger.debug("👋 Sending leave announcement", category: .session) - let packet = BitchatPacket( - type: MessageType.leave.rawValue, - ttl: messageTTL, - senderID: myPeerID, - payload: Data(myNickname.utf8) - ) - broadcastPacket(packet) - } - private func sendAnnounce(forceSend: Bool = false) { // Throttle announces to prevent flooding let now = Date() @@ -2059,8 +2013,6 @@ extension BLEService: CBPeripheralDelegate { self?.sendAnnounce(forceSend: true) // Try flushing any spooled directed packets now that we have a link self?.flushDirectedSpool() - // Rebroadcast a couple of recent announces to seed the new link - self?.rebroadcastRecentAnnounces() } } else { SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session) @@ -2302,10 +2254,9 @@ extension BLEService: CBPeripheralManagerDelegate { return } - // Still flush directed packets and rebroadcast for legitimate mesh operation + // Still flush directed packets for legitimate mesh operation messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.flushDirectedSpool() - self?.rebroadcastRecentAnnounces() } return } @@ -2331,8 +2282,6 @@ extension BLEService: CBPeripheralManagerDelegate { self?.sendAnnounce(forceSend: true) // Flush any spooled directed packets now that we have a central subscribed self?.flushDirectedSpool() - // Rebroadcast a couple of recent announces to seed the new link - self?.rebroadcastRecentAnnounces() } } @@ -3715,9 +3664,6 @@ extension BLEService { claimedNickname: announcement.nickname ) - // Record this announce for lightweight rebroadcast buffer (exclude self) - // (recentAnnounces has been removed in the refactor) - // Notify UI on main thread notifyUI { [weak self] in guard let self = self else { return } diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index 1fe6b332..a6fdc102 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -31,9 +31,6 @@ final class FavoritesPersistenceService: ObservableObject { @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var mutualFavorites: Set = [] - private let userDefaults = UserDefaults.standard - private var cancellables = Set() - static let shared = FavoritesPersistenceService() init(keychain: KeychainManagerProtocol = KeychainManager()) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index cac0c298..c0f4488a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -279,8 +279,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname // Show Tor status once per app launch var torStatusAnnounced = false - private var torProgressCancellable: AnyCancellable? - private var lastTorProgressAnnounced = -1 // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false @@ -323,8 +321,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv ) // Channel activity tracking for background nudges var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time - private var lastPublicActivityNotifyAt: [String: Date] = [:] - private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds // Geohash participant tracker let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) @@ -503,8 +499,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv addGeohashOnlySystemMessage( String(localized: "system.tor.starting", comment: "System message when Tor is starting") ) - // Suppress incremental Tor progress messages - torProgressCancellable = nil } else if !TorManager.shared.torEnforced && !torStatusAnnounced { torStatusAnnounced = true addGeohashOnlySystemMessage( @@ -631,8 +625,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv object: nil ) - // Listen for delivery acknowledgments - // When app becomes active, send read receipts for visible messages #if os(macOS) NotificationCenter.default.addObserver( @@ -1468,56 +1460,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv selectedPrivateChatFingerprint = nil } - // MARK: - Nostr Message Handling - - @MainActor - @objc private func handleNostrMessage(_ notification: Notification) { - guard let message = notification.userInfo?["message"] as? BitchatMessage else { return } - - // Store the Nostr pubkey if provided (for messages from unknown senders) - if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String, - let senderPeerID = message.senderPeerID { - // Store mapping for read receipts - nostrKeyMapping[senderPeerID] = nostrPubkey - } - - // Process the Nostr message through the same flow as Bluetooth messages - didReceiveMessage(message) - } - - @objc private func handleDeliveryAcknowledgment(_ notification: Notification) { - guard let messageId = notification.userInfo?["messageId"] as? String else { return } - - - - // Update the delivery status for the message - if let index = messages.firstIndex(where: { $0.id == messageId }) { - // Update delivery status to delivered - messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date()) - - // Schedule UI update for delivery status - // UI will update automatically - } - - // Also update in private chats if it's a private message - for (peerID, chatMessages) in privateChats { - if let index = chatMessages.firstIndex(where: { $0.id == messageId }) { - privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date()) - // UI will update automatically - break - } - } - } - - @objc private func handleNostrReadReceipt(_ notification: Notification) { - guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return } - - SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session) - - // Process the read receipt through the same flow as Bluetooth read receipts - didReceiveReadReceipt(receipt) - } - @MainActor @objc private func handlePeerStatusUpdate(_ notification: Notification) { // Update private chat peer if needed when peer status changes diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index cbeedfeb..00efc924 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -15,10 +15,22 @@ struct PaymentChipView: View { enum PaymentType { case cashu(String) case lightning(String) - + + private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + + private static func cashuURL(from link: String) -> URL? { + if let url = URL(string: link), url.scheme != nil { + return url + } + let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link + return URL(string: "cashu:\(enc)") + } + var url: URL? { switch self { - case .cashu(let link), .lightning(let link): + case .cashu(let link): + return Self.cashuURL(from: link) + case .lightning(let link): return URL(string: link) } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ba69c959..36c379a4 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -40,7 +40,6 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var showPeerList = false @State private var showSidebar = false @State private var showAppInfo = false @State private var showMessageActions = false @@ -57,7 +56,6 @@ struct ContentView: View { @State private var expandedMessageIDs: Set = [] @State private var showLocationNotes = false @State private var notesGeohash: String? = nil - @State private var sheetNotesCount: Int = 0 @State private var imagePreviewURL: URL? = nil @State private var recordingAlertMessage: String = "" @State private var showRecordingAlert = false @@ -1181,19 +1179,6 @@ struct ContentView: View { ) } - // Split a name into base and a '#abcd' suffix if present - private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) { - guard name.count >= 5 else { return (name, "") } - let suffix = String(name.suffix(5)) - if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in - ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) - }) { - let base = String(name.dropLast(5)) - return (base, suffix) - } - return (name, "") - } - // Compute channel-aware people count and color for toolbar (cross-platform) private func channelPeopleCountAndColor() -> (Int, Color) { switch locationManager.selectedChannel { @@ -1308,8 +1293,8 @@ struct ContentView: View { // Bookmark toggle (geochats): to the left of #geohash if case .location(let ch) = locationManager.selectedChannel { - Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) { - Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") + Button(action: { bookmarks.toggle(ch.geohash) }) { + Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 12)) } .buttonStyle(.plain) @@ -1567,7 +1552,7 @@ private extension ContentView { } else if let media = mediaAttachment(for: message) { mediaMessageRow(message: message, media: media) } else { - textMessageRow(message) + TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) } } @@ -1631,56 +1616,6 @@ private extension ContentView { .padding(.vertical, 4) } - @ViewBuilder - private func textMessageRow(_ message: BitchatMessage) -> some View { - let cashuTokens = message.content.extractCashuLinks() - let lightningLinks = message.content.extractLightningLinks() - let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty - let isExpanded = expandedMessageIDs.contains(message.id) - - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 0) { - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) - .frame(maxWidth: .infinity, alignment: .leading) - - if message.isPrivate && message.sender == viewModel.nickname, - let status = message.deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) - } - } - - if isLong && cashuTokens.isEmpty { - let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") - Button(labelKey) { - if isExpanded { expandedMessageIDs.remove(message.id) } - else { expandedMessageIDs.insert(message.id) } - } - .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(Color.blue) - .padding(.top, 4) - } - - if !lightningLinks.isEmpty || !cashuTokens.isEmpty { - HStack(spacing: 8) { - ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in - PaymentChipView(paymentType: .lightning(link)) - } - - ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in - let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token - let urlStr = "cashu:\(enc)" - PaymentChipView(paymentType: .cashu(urlStr)) - } - } - .padding(.top, 6) - .padding(.leading, 2) - } - } - } - private func expandWindow(ifNeededFor message: BitchatMessage, allMessages: [BitchatMessage], privatePeer: PeerID?, diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index d4d8095c..bd653ec9 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -5,21 +5,6 @@ import Foundation -private enum RegexCache { - static let cashu: NSRegularExpression = { - try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) - }() - static let lightningScheme: NSRegularExpression = { - try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", 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: []) - }() -} - extension String { // Detect if there is an extremely long token (no whitespace/newlines) that could break layout func hasVeryLongToken(threshold: Int) -> Bool { @@ -38,7 +23,7 @@ extension String { // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. func extractCashuLinks(max: Int = 3) -> [String] { - let regex = RegexCache.cashu + let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] @@ -59,19 +44,19 @@ extension String { let ns = self as NSString let full = NSRange(location: 0, length: ns.length) // lightning: scheme - for m in RegexCache.lightningScheme.matches(in: self, range: full) { + for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append(s) if results.count >= max { return results } } // BOLT11 - for m in RegexCache.bolt11.matches(in: self, range: full) { + for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append("lightning:\(s)") if results.count >= max { return results } } // LNURL bech32 - for m in RegexCache.lnurl.matches(in: self, range: full) { + for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) { let s = ns.substring(with: m.range(at: 0)) results.append("lightning:\(s)") if results.count >= max { return results } diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index e507fe25..7feebc50 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -134,7 +134,7 @@ struct FragmentationTests { } } - try await sleep(1.0) + try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2)) let message = try #require(capture.receivedMessages.first, "Expected file transfer message") #expect(message.content.hasPrefix("[file]")) diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 4816b504..c84708a7 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -13,6 +13,7 @@ struct GossipSyncManagerTests { try await confirmation("sync request sent") { sent in delegate.onSend = { + delegate.onSend = nil sent() } @@ -34,7 +35,7 @@ struct GossipSyncManagerTests { } manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await sleep(0.002) + try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout) } let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent") @@ -240,7 +241,7 @@ struct GossipSyncManagerTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment) manager.handleRequestSync(from: peer, request: request) - try await sleep(0.01) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) let sentPackets = delegate.packets #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index fea921ba..1986445f 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -746,8 +746,8 @@ struct NoiseProtocolTests { } // Get transport ciphers - let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false) - let (respSend, respRecv) = try responderHandshake.getTransportCiphers(useExtractedNonce: false) + let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false) + let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false) // Test transport messages (messages after the 3 handshake messages) for index in 3..