mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:45: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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user