Feat/b links (#481)

* feat(cashu): auto-link cashu tokens in chat

- Detect cashuA/cashuB tokens via regex and render as tappable links
- Style like URLs (underline; blue for others, orange for self)
- Add openURL handler for cashu: scheme to delegate to wallet on iOS
- Respect existing heavy-content gating and long-message collapsing

* feat(ln): auto-link Lightning invoices and LNURL + lightning: scheme\n\n- Detect BOLT11 (lnbc/lntb/lnbcrt...), LNURL bech32, and lightning: URIs\n- Render as tappable links with lightning: scheme; consistent styling\n- Handle lightning: in openURL alongside cashu:

* feat(links): replace raw Cashu/Lightning tokens with compact chips (🥜 pay via cashu /  pay via lightning) while keeping them tappable

* style(links): add subtle background highlight to cashu/lightning chips

* ui(links): render Lightning/Cashu as rounded chips under message with padding; remove inline chip text

* ui(links): increase chip padding and corner radius; add extra top padding for chip row

* scroll: auto-scroll to bottom when sending a new message (public + private), regardless of current scroll position

* scroll(geo): when switching geohashes, scroll to top of chat (first visible message)

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-22 17:20:26 +02:00
committed by GitHub
co-authored by jack
parent 7f2cbd6621
commit b2504a7ff5
3 changed files with 237 additions and 13 deletions
+59 -9
View File
@@ -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()
+129 -4
View File
@@ -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
+49
View File
@@ -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:<payload>
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 {