diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 141c1d6e..545818e0 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -91,9 +91,134 @@ import UIKit /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. class ChatViewModel: ObservableObject, BitchatDelegate { + // Precompiled regexes and detectors reused across formatting + 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: []) + }() + static let simplifyHTTPURL: NSRegularExpression = { + try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive]) + }() + } + + // MARK: - Spam resilience: token buckets + private struct TokenBucket { + var capacity: Double + var tokens: Double + var refillPerSec: Double + var lastRefill: Date + + mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool { + let dt = now.timeIntervalSince(lastRefill) + if dt > 0 { + tokens = min(capacity, tokens + dt * refillPerSec) + lastRefill = now + } + if tokens >= cost { + tokens -= cost + return true + } + return false + } + } + + private var rateBucketsBySender: [String: TokenBucket] = [:] + private var rateBucketsByContent: [String: TokenBucket] = [:] + private let senderBucketCapacity: Double = 5 + private let senderBucketRefill: Double = 1 // tokens per second + private let contentBucketCapacity: Double = 3 + private let contentBucketRefill: Double = 0.5 // tokens per second + + @MainActor + private func normalizedSenderKey(for message: BitchatMessage) -> String { + if let spid = message.senderPeerID { + 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] ?? bare).lowercased() + return "nostr:" + full + } else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { + return "noise:" + full + } else { + return "mesh:" + spid.lowercased() + } + } + return "name:" + message.sender.lowercased() + } + + private func normalizedContentKey(_ content: String) -> String { + // Lowercase, simplify URLs (strip query/fragment), collapse whitespace, bound length + let lowered = content.lowercased() + let ns = lowered as NSString + let range = NSRange(location: 0, length: ns.length) + var simplified = "" + var last = 0 + for m in Regexes.simplifyHTTPURL.matches(in: lowered, options: [], range: range) { + if m.range.location > last { + simplified += ns.substring(with: NSRange(location: last, length: m.range.location - last)) + } + let url = ns.substring(with: m.range) + if let q = url.firstIndex(where: { $0 == "?" || $0 == "#" }) { + simplified += String(url[.. contentLRUCap { + let overflow = contentLRUOrder.count - contentLRUCap + for _ in 0..() + + // MARK: - Public message batching (UI perf) + // Buffer incoming public messages and flush in small batches to reduce UI invalidations + private var publicBuffer: [BitchatMessage] = [] + private var publicBufferTimer: Timer? = nil + private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching + private var dynamicPublicFlushInterval: TimeInterval = 0.08 + private var recentBatchSizes: [Int] = [] + @Published private(set) var isBatchingPublic: Bool = false + private let lateInsertThreshold: TimeInterval = 15.0 // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -410,10 +545,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { .receive(on: DispatchQueue.main) .sink { [weak self] peers in guard let self = self else { return } - // Update peers directly + // Update peers directly; @Published drives UI updates self.allPeers = peers - // Force UI update - self.objectWillChange.send() // Update peer index for O(1) lookups // Deduplicate peers by ID to prevent crash from duplicate keys var uniquePeers: [String: BitchatPeer] = [:] @@ -1095,6 +1228,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Add to main messages immediately for user feedback messages.append(message) + // Update content LRU for near-dup detection + let ckey = normalizedContentKey(message.content) + recordContentKey(ckey, timestamp: message.timestamp) // Persist to channel-specific timelines #if os(iOS) switch activeChannel { @@ -1104,10 +1240,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { case .location(let ch): var arr = geoTimelines[ch.geohash] ?? [] arr.append(message) - if arr.count > geoTimelineCap { - let remove = arr.count - geoTimelineCap - arr.removeFirst(remove) - } + if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } geoTimelines[ch.geohash] = arr } #else @@ -1173,6 +1306,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { #if os(iOS) @MainActor private func switchLocationChannel(to channel: ChannelID) { + // Flush pending public buffer to avoid cross-channel bleed + publicBufferTimer?.invalidate(); publicBufferTimer = nil + publicBuffer.removeAll(keepingCapacity: false) activeChannel = channel // Reset deduplication set and optionally hydrate timeline for mesh processedNostrEvents.removeAll() @@ -2873,11 +3009,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // For extremely long content, render as plain text to avoid heavy regex/layout work, // unless the content includes Cashu tokens we want to chip-render below let containsCashuEarly: Bool = { - let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" - if let rx = try? NSRegularExpression(pattern: pattern, options: []) { - return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 - } - return false + let rx = Regexes.quickCashuPresence + return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 }() if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { var plainStyle = AttributeContainer() @@ -2887,33 +3020,29 @@ class ChatViewModel: ObservableObject, BitchatDelegate { : .system(size: 14, design: .monospaced) result.append(AttributedString(content).mergingAttributes(plainStyle)) } else { - let hashtagPattern = "#([a-zA-Z0-9_]+)" - // Allow optional '#abcd' suffix in mentions - let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" - // Cashu token detector: cashuA/cashuB + long base64url; allow '.' and shorter variants - let cashuPattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" - // Lightning invoices and links - let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b" - let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b" - let lightningSchemePattern = "(?i)\\blightning:[^\\s]+" + // Reuse compiled regexes and detector + let hashtagRegex = Regexes.hashtag + let mentionRegex = Regexes.mention + let cashuRegex = Regexes.cashu + let bolt11Regex = Regexes.bolt11 + let lnurlRegex = Regexes.lnurl + let lightningSchemeRegex = Regexes.lightningScheme + let detector = Regexes.linkDetector - let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) - let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) - let cashuRegex = try? NSRegularExpression(pattern: cashuPattern, options: []) - let bolt11Regex = try? NSRegularExpression(pattern: bolt11Pattern, options: []) - let lnurlRegex = try? NSRegularExpression(pattern: lnurlPattern, options: []) - let lightningSchemeRegex = try? NSRegularExpression(pattern: lightningSchemePattern, options: []) - - // Use NSDataDetector for URL detection - let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - - let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let cashuMatches = cashuRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let lightningMatches = lightningSchemeRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let bolt11Matches = bolt11Regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let lnurlMatches = lnurlRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + let nsLen = content.count + 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 ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : [] + let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] + let lnurlMatches = hasLightningHint ? lnurlRegex.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) } @@ -3343,8 +3472,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private func trimMessagesIfNeeded() { if messages.count > maxMessages { - let removeCount = messages.count - maxMessages - messages.removeFirst(removeCount) + messages = Array(messages.suffix(maxMessages)) } } @@ -3413,8 +3541,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private func trimMeshTimelineIfNeeded() { if meshTimeline.count > meshTimelineCap { - let removeCount = meshTimeline.count - meshTimelineCap - meshTimeline.removeFirst(removeCount) + meshTimeline = Array(meshTimeline.suffix(meshTimelineCap)) } } @@ -3445,9 +3572,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private func addMessage(_ message: BitchatMessage) { // Check for duplicates guard !messages.contains(where: { $0.id == message.id }) else { return } - messages.append(message) - messages.sort { $0.timestamp < $1.timestamp } trimMessagesIfNeeded() } @@ -4951,8 +5076,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { privateChats = chats trimPrivateChatMessagesIfNeeded(for: peerID) - // Trigger UI update - objectWillChange.send() + // UI updates via @Published reassignment above // Handle fingerprint-based chat updates if let chatFingerprint = selectedPrivateChatFingerprint, @@ -5025,6 +5149,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true + // Apply per-sender and per-content rate limits (drop if exceeded) + if finalMessage.sender != "system" { + let senderKey = normalizedSenderKey(for: finalMessage) + let contentKey = normalizedContentKey(finalMessage.content) + let now = Date() + var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now) + let senderAllowed = sBucket.allow(now: now) + rateBucketsBySender[senderKey] = sBucket + var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now) + let contentAllowed = cBucket.allow(now: now) + rateBucketsByContent[contentKey] = cBucket + if !(senderAllowed && contentAllowed) { return } + } + + // Size cap: drop extremely large public messages early + if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } + // Persist mesh messages to mesh timeline always if !isGeo && finalMessage.sender != "system" { meshTimeline.append(finalMessage) @@ -5037,10 +5178,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if let gh = currentGeohash { var arr = geoTimelines[gh] ?? [] arr.append(finalMessage) - if arr.count > geoTimelineCap { - let remove = arr.count - geoTimelineCap - arr.removeFirst(remove) - } + if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } geoTimelines[gh] = arr } } @@ -5086,32 +5224,103 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } #endif - // Check if this is our own message being echoed back (avoid dup) - if finalMessage.sender != nickname && finalMessage.sender != "system" { - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) - } - return + // Append via batching buffer (skip empty content) + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + enqueuePublic(finalMessage) } + } - if finalMessage.sender != "system" { - // Check for duplicates - let messageExists = messages.contains { existingMsg in - if existingMsg.id == finalMessage.id { return true } - if existingMsg.content == finalMessage.content && existingMsg.sender == finalMessage.sender { - let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) - return timeDiff < 1.0 - } - return false + // MARK: - Public message batching helpers + @MainActor + private func enqueuePublic(_ message: BitchatMessage) { + publicBuffer.append(message) + schedulePublicFlush() + } + + @MainActor + private func schedulePublicFlush() { + if publicBufferTimer != nil { return } + publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval, + target: self, + selector: #selector(onPublicBufferTimerFired(_:)), + userInfo: nil, + repeats: false) + } + + @MainActor + private func flushPublicBuffer() { + publicBufferTimer?.invalidate() + publicBufferTimer = nil + guard !publicBuffer.isEmpty else { return } + + // Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders + var seenIDs = Set(messages.map { $0.id }) + var added: [BitchatMessage] = [] + var batchContentLatest: [String: Date] = [:] + for m in publicBuffer { + if seenIDs.contains(m.id) { continue } + let ckey = normalizedContentKey(m.content) + if let ts = contentLRUMap[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue } + if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue } + seenIDs.insert(m.id) + added.append(m) + batchContentLatest[ckey] = m.timestamp + } + publicBuffer.removeAll(keepingCapacity: true) + guard !added.isEmpty else { return } + + // Indicate batching for conditional UI animations + isBatchingPublic = true + // Rough chronological order: sort the batch by timestamp before inserting + added.sort { $0.timestamp < $1.timestamp } + // Insert late arrivals into approximate position; append recent ones + let lastTs = messages.last?.timestamp ?? .distantPast + for m in added { + if m.timestamp < lastTs.addingTimeInterval(-lateInsertThreshold) { + let idx = insertionIndexByTimestamp(m.timestamp) + if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) } + } else { + messages.append(m) } - if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) - } - } else { - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - addMessage(finalMessage) + // Record content key for LRU + let ckey = normalizedContentKey(m.content) + recordContentKey(ckey, timestamp: m.timestamp) + } + trimMessagesIfNeeded() + // Update batch size stats and adjust interval + recentBatchSizes.append(added.count) + if recentBatchSizes.count > 10 { recentBatchSizes.removeFirst(recentBatchSizes.count - 10) } + let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count) + dynamicPublicFlushInterval = avg > 100.0 ? 0.12 : basePublicFlushInterval + // Prewarm formatting cache for current UI color scheme only + for m in added { + _ = self.formatMessageAsText(m, colorScheme: currentColorScheme) + } + // Reset batching flag (already on main actor) + isBatchingPublic = false + // If new items arrived during this flush, coalesce by flushing once more next tick + if !publicBuffer.isEmpty { schedulePublicFlush() } + } + + // Timer selector to avoid @Sendable closure capture issues under Swift 6 + @MainActor @objc + private func onPublicBufferTimerFired(_ timer: Timer) { + flushPublicBuffer() + } + + @MainActor + private func insertionIndexByTimestamp(_ ts: Date) -> Int { + var low = 0 + var high = messages.count + while low < high { + let mid = (low + high) / 2 + if messages[mid].timestamp < ts { + low = mid + 1 + } else { + high = mid } } + return low } /// Check for mentions and send notifications diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 94d77c19..ec1ddd1f 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -84,6 +84,10 @@ struct ContentView: View { ZStack { // Base layer - Main public chat (always visible) mainChatView + .onAppear { viewModel.currentColorScheme = colorScheme } + .onChange(of: colorScheme) { newValue in + viewModel.currentColorScheme = newValue + } // Private chat slide-over if viewModel.selectedPrivateChatPeer != nil { @@ -310,9 +314,11 @@ struct ContentView: View { } else { // Regular messages with natural text wrapping VStack(alignment: .leading, spacing: 0) { + // Precompute heavy token scans once per row + let cashuTokens = message.content.extractCashuTokens() + let lightningLinks = message.content.extractLightningLinks() HStack(alignment: .top, spacing: 0) { - // Single text view for natural wrapping - let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty + let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) @@ -328,9 +334,9 @@ struct ContentView: View { } // Expand/Collapse for very long messages - if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty { + if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty { let isExpanded = expandedMessageIDs.contains(message.id) - Button(isExpanded ? "Show less" : "Show more") { + Button(isExpanded ? "show less" : "show more") { if isExpanded { expandedMessageIDs.remove(message.id) } else { expandedMessageIDs.insert(message.id) } } @@ -339,11 +345,7 @@ struct ContentView: View { .padding(.top, 4) } - // Link previews removed: URLs appear inline and are tappable - // Render payment chips (Lightning / Cashu) with rounded background - let lightningLinks = message.content.extractLightningLinks() - let cashuTokens = message.content.extractCashuTokens() if !lightningLinks.isEmpty || !cashuTokens.isEmpty { HStack(spacing: 8) { ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in @@ -453,6 +455,7 @@ struct ContentView: View { .padding(.vertical, 2) } } + .transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 4) } .background(backgroundColor) diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index 497b51c4..1ef4c19e 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -5,6 +5,21 @@ 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 { @@ -23,8 +38,7 @@ extension String { // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. func extractCashuTokens(max: Int = 3) -> [String] { - let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + let regex = RegexCache.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] @@ -44,28 +58,22 @@ extension String { let ns = self as NSString let full = NSRange(location: 0, length: ns.length) // lightning: scheme - if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) { - for m in schemeRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append(s) - if results.count >= max { return results } - } + for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append(s) + if results.count >= max { return results } } // BOLT11 - if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) { - for m in boltRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append("lightning:\(s)") - if results.count >= max { return results } - } + for m in RegexCache.bolt11.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append("lightning:\(s)") + if results.count >= max { return results } } // LNURL bech32 - if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) { - for m in lnurlRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append("lightning:\(s)") - if results.count >= max { return results } - } + for m in RegexCache.lnurl.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append("lightning:\(s)") + if results.count >= max { return results } } return results }