mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04: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)
|
: .system(size: 14, design: .monospaced)
|
||||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
} else {
|
} else {
|
||||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
||||||
// Allow optional '#abcd' suffix in mentions
|
// Allow optional '#abcd' suffix in mentions
|
||||||
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
||||||
|
// Cashu token detector: cashuA/cashuB + long base64url
|
||||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
let cashuPattern = "\\bcashu[AB][A-Za-z0-9_-]{60,}\\b"
|
||||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
// Lightning invoices and links
|
||||||
|
let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b"
|
||||||
// Use NSDataDetector for URL detection
|
let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b"
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
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 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 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 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
|
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||||
@@ -2815,6 +2829,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
for match in urlMatches where !overlapsMention(match.range) {
|
for match in urlMatches where !overlapsMention(match.range) {
|
||||||
allMatches.append((match.range, "url"))
|
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 }
|
allMatches.sort { $0.range.location < $1.range.location }
|
||||||
|
|
||||||
// Build content with styling
|
// Build content with styling
|
||||||
@@ -2884,6 +2917,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
: .system(size: 14, design: .monospaced)
|
: .system(size: 14, design: .monospaced)
|
||||||
tagStyle.foregroundColor = baseColor
|
tagStyle.foregroundColor = baseColor
|
||||||
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
|
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 {
|
} else {
|
||||||
// Keep URL styling (blue + underline for non-self, orange for self)
|
// Keep URL styling (blue + underline for non-self, orange for self)
|
||||||
var matchStyle = AttributeContainer()
|
var matchStyle = AttributeContainer()
|
||||||
|
|||||||
@@ -357,6 +357,46 @@ struct ContentView: View {
|
|||||||
.id("\(message.id)-\(urlInfo.url.absoluteString)")
|
.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
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
if privatePeer == nil && !viewModel.messages.isEmpty {
|
if privatePeer == nil && !viewModel.messages.isEmpty {
|
||||||
// Only autoscroll when user is at/near bottom
|
// If the newest message is from me, always scroll to bottom
|
||||||
guard isAtBottom.wrappedValue else { return }
|
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
|
// Throttle scroll animations to prevent excessive UI updates
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
||||||
@@ -525,8 +573,15 @@ struct ContentView: View {
|
|||||||
if let peerID = privatePeer,
|
if let peerID = privatePeer,
|
||||||
let messages = viewModel.privateChats[peerID],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
// Only autoscroll when user is at/near bottom
|
// If the newest private message is from me, always scroll
|
||||||
guard isAtBottom.wrappedValue else { return }
|
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
|
// Same throttling for private chats
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if now.timeIntervalSince(lastScrollTime) > 0.5 {
|
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 {
|
.onAppear {
|
||||||
// Also check when view appears
|
// Also check when view appears
|
||||||
if let peerID = privatePeer {
|
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
|
// MARK: - Input View
|
||||||
@@ -1257,6 +1344,44 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Helper Views
|
// 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)
|
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
||||||
struct MessageContentView: View {
|
struct MessageContentView: View {
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
|
|||||||
@@ -439,6 +439,55 @@ extension String {
|
|||||||
}
|
}
|
||||||
return current >= threshold
|
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 {
|
#Preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user