mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 00:05:20 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user