diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 843c6d22..4047fcd1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2785,19 +2785,33 @@ 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})?)" - - let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) - let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) - - // Use NSDataDetector for URL detection - let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + 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 + let cashuPattern = "\\bcashu[AB][A-Za-z0-9_-]{60,}\\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]+" + + 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)) ?? [] // Combine and sort matches, excluding hashtags/URLs overlapping mentions let mentionRanges = mentionMatches.map { $0.range(at: 0) } @@ -2815,6 +2829,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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")) + } + // Lightning scheme first to avoid overlapping submatches + 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 @@ -2884,6 +2917,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate { : .system(size: 14, design: .monospaced) tagStyle.foregroundColor = baseColor result.append(AttributedString(matchText).mergingAttributes(tagStyle)) + } else if type == "cashu" { + // Skip inline token; a styled chip is rendered below the message + // We insert a single space to avoid words sticking together + var spacer = AttributeContainer() + spacer.foregroundColor = baseColor + spacer.font = isSelf + ? .system(size: 14, weight: .bold, design: .monospaced) + : .system(size: 14, design: .monospaced) + result.append(AttributedString(" ").mergingAttributes(spacer)) + } else if type == "lightning" || type == "bolt11" || type == "lnurl" { + // Skip inline invoice/link; a styled chip is rendered below the message + var spacer = AttributeContainer() + spacer.foregroundColor = baseColor + spacer.font = isSelf + ? .system(size: 14, weight: .bold, design: .monospaced) + : .system(size: 14, design: .monospaced) + result.append(AttributedString(" ").mergingAttributes(spacer)) } else { // Keep URL styling (blue + underline for non-self, orange for self) var matchStyle = AttributeContainer() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 5ec0ab8d..30449cb6 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -357,6 +357,46 @@ struct ContentView: View { .id("\(message.id)-\(urlInfo.url.absoluteString)") } } + + // 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 + let link = lightningLinks[i] + PaymentChipView( + emoji: "⚡", + label: "pay via lightning", + colorScheme: colorScheme + ) { + #if os(iOS) + if let url = URL(string: link) { UIApplication.shared.open(url) } + #else + if let url = URL(string: link) { NSWorkspace.shared.open(url) } + #endif + } + } + ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { i in + let token = cashuTokens[i] + let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token + let urlStr = "cashu:\(enc)" + PaymentChipView( + emoji: "🥜", + label: "pay via cashu", + colorScheme: colorScheme + ) { + #if os(iOS) + if let url = URL(string: urlStr) { UIApplication.shared.open(url) } + #else + if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) } + #endif + } + } + } + .padding(.top, 6) + .padding(.leading, 2) + } } } } @@ -477,8 +517,16 @@ struct ContentView: View { } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { - // Only autoscroll when user is at/near bottom - guard isAtBottom.wrappedValue else { return } + // If the newest message is from me, always scroll to bottom + let lastMsg = viewModel.messages.last! + let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") + if !isFromSelf { + // Only autoscroll when user is at/near bottom + guard isAtBottom.wrappedValue else { return } + } else { + // Ensure we consider ourselves at bottom for subsequent messages + isAtBottom.wrappedValue = true + } // Throttle scroll animations to prevent excessive UI updates let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -525,8 +573,15 @@ struct ContentView: View { if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { - // Only autoscroll when user is at/near bottom - guard isAtBottom.wrappedValue else { return } + // If the newest private message is from me, always scroll + let lastMsg = messages.last! + let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") + if !isFromSelf { + // Only autoscroll when user is at/near bottom + guard isAtBottom.wrappedValue else { return } + } else { + isAtBottom.wrappedValue = true + } // Same throttling for private chats let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -549,6 +604,25 @@ struct ContentView: View { } } } + #if os(iOS) + .onChange(of: locationManager.selectedChannel) { newChannel in + // When switching to a new geohash channel, scroll to the top of that chat + guard privatePeer == nil else { return } + switch newChannel { + case .mesh: + break + case .location(let ch): + // Compute the top item (first of the current window) + let contextKey = "geo:\(ch.geohash)" + let top = viewModel.messages.suffix(100).first?.id + let target = top.map { "\(contextKey)|\($0)" } + isAtBottom.wrappedValue = false + DispatchQueue.main.async { + if let target = target { proxy.scrollTo(target, anchor: .top) } + } + } + } + #endif .onAppear { // Also check when view appears if let peerID = privatePeer { @@ -565,6 +639,19 @@ struct ContentView: View { } } } + .environment(\.openURL, OpenURLAction { url in + // Intercept custom cashu: links created in attributed text + if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" { + #if os(iOS) + UIApplication.shared.open(url) + return .handled + #else + // On non-iOS platforms, let the system handle or ignore + return .systemAction + #endif + } + return .systemAction + }) } // MARK: - Input View @@ -1257,6 +1344,44 @@ struct ContentView: View { // MARK: - Helper Views +// Rounded payment chip button +private struct PaymentChipView: View { + let emoji: String + let label: String + let colorScheme: ColorScheme + let action: () -> Void + + private var fgColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + private var bgColor: Color { + colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12) + } + private var border: Color { fgColor.opacity(0.25) } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Text(emoji) + Text(label) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(bgColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(border, lineWidth: 1) + ) + .foregroundColor(fgColor) + } + .buttonStyle(.plain) + } +} + // Helper view for rendering message content (plain, no hashtag/mention formatting) struct MessageContentView: View { let message: BitchatMessage diff --git a/bitchat/Views/LinkPreviewView.swift b/bitchat/Views/LinkPreviewView.swift index 2db79759..e1300bd7 100644 --- a/bitchat/Views/LinkPreviewView.swift +++ b/bitchat/Views/LinkPreviewView.swift @@ -439,6 +439,55 @@ extension String { } return current >= threshold } + + // Extract up to `max` Cashu tokens (cashuA/cashuB) + func extractCashuTokens(max: Int = 3) -> [String] { + let pattern = "\\bcashu[AB][A-Za-z0-9_-]{60,}\\b" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + let ns = self as NSString + let range = NSRange(location: 0, length: ns.length) + var found: [String] = [] + for m in regex.matches(in: self, options: [], range: range) { + if m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + found.append(token) + if found.count >= max { break } + } + } + return found + } + + // Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning: + func extractLightningLinks(max: Int = 3) -> [String] { + var results: [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 } + } + } + // 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 } + } + } + // 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 } + } + } + return results + } } #Preview {