mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Add rich link previews for shared URLs
- Capture both URL and title when sharing links - Create LinkPreviewView component with clickable cards - Support markdown-style links [title](url) - Auto-detect plain URLs in messages - Add link metadata fetching on iOS - Display clean preview cards with title, URL, and description
This commit is contained in:
@@ -92,8 +92,21 @@ struct BitchatApp: App {
|
||||
|
||||
// Send the shared content after a short delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
let prefix = contentType == "url" ? "Shared link: " : ""
|
||||
self.chatViewModel.sendMessage(prefix + sharedContent)
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"],
|
||||
let title = urlData["title"] {
|
||||
// Send formatted link with title and URL
|
||||
self.chatViewModel.sendMessage("[\(title)](\(url))")
|
||||
} else {
|
||||
// Fallback to simple URL
|
||||
self.chatViewModel.sendMessage("Shared link: \(sharedContent)")
|
||||
}
|
||||
} else {
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,18 +415,33 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
// Regular messages with natural text wrapping
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Single text view for natural wrapping
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Single text view for natural wrapping
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
// Check for links and show preview
|
||||
if let markdownLink = message.content.extractMarkdownLink() {
|
||||
LinkPreviewView(url: markdownLink.url, title: markdownLink.title)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
// Check for plain URLs
|
||||
let urls = message.content.extractURLs()
|
||||
ForEach(urls.prefix(3), id: \.url) { urlInfo in
|
||||
LinkPreviewView(url: urlInfo.url, title: nil)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// LinkPreviewView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import LinkPresentation
|
||||
#endif
|
||||
|
||||
struct LinkPreviewView: View {
|
||||
let url: URL
|
||||
let title: String?
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var isLoadingMetadata = false
|
||||
@State private var metadata: LinkMetadata?
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
textColor.opacity(0.3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Title
|
||||
Text(metadata?.title ?? title ?? url.host ?? "Link")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// URL
|
||||
Text(url.absoluteString)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
// Description if available
|
||||
if let description = metadata?.description {
|
||||
Text(description)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(backgroundColor.opacity(0.05))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
#if os(iOS)
|
||||
guard metadata == nil && !isLoadingMetadata else { return }
|
||||
|
||||
isLoadingMetadata = true
|
||||
let provider = LPMetadataProvider()
|
||||
provider.startFetchingMetadata(for: url) { metadata, error in
|
||||
DispatchQueue.main.async {
|
||||
if let metadata = metadata {
|
||||
self.metadata = LinkMetadata(
|
||||
title: metadata.title,
|
||||
description: metadata.value(forKey: "_summary") as? String,
|
||||
imageURL: metadata.imageProvider?.value(forKey: "_URL") as? URL
|
||||
)
|
||||
}
|
||||
self.isLoadingMetadata = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkMetadata {
|
||||
let title: String?
|
||||
let description: String?
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
// Helper to extract URLs from text
|
||||
extension String {
|
||||
func extractURLs() -> [(url: URL, range: Range<String.Index>)] {
|
||||
var urls: [(URL, Range<String.Index>)] = []
|
||||
|
||||
// Check for markdown-style links [title](url)
|
||||
let markdownPattern = #"\[([^\]]+)\]\(([^)]+)\)"#
|
||||
if let regex = try? NSRegularExpression(pattern: markdownPattern) {
|
||||
let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
|
||||
for match in matches {
|
||||
if let urlRange = Range(match.range(at: 2), in: self),
|
||||
let url = URL(string: String(self[urlRange])),
|
||||
let fullRange = Range(match.range, in: self) {
|
||||
urls.append((url, fullRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for plain URLs
|
||||
let types: NSTextCheckingResult.CheckingType = .link
|
||||
if let detector = try? NSDataDetector(types: types.rawValue) {
|
||||
let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
|
||||
for match in matches {
|
||||
if let range = Range(match.range, in: self),
|
||||
let url = match.url {
|
||||
// Don't add if this URL is already part of a markdown link
|
||||
let isPartOfMarkdown = urls.contains { $0.range.overlaps(range) }
|
||||
if !isPartOfMarkdown {
|
||||
urls.append((url, range))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
func extractMarkdownLink() -> (title: String, url: URL)? {
|
||||
let pattern = #"\[([^\]]+)\]\(([^)]+)\)"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: self, range: NSRange(location: 0, length: self.utf16.count)) {
|
||||
if let titleRange = Range(match.range(at: 1), in: self),
|
||||
let urlRange = Range(match.range(at: 2), in: self),
|
||||
let url = URL(string: String(self[urlRange])) {
|
||||
return (String(self[titleRange]), url)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
LinkPreviewView(url: URL(string: "https://example.com")!, title: "Example Website")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -111,9 +111,27 @@ class ShareViewController: SLComposeServiceViewController {
|
||||
}
|
||||
|
||||
private func handleSharedURL(_ url: URL) {
|
||||
// Convert URL to text and share
|
||||
let text = url.absoluteString
|
||||
saveToSharedDefaults(content: text, type: "url")
|
||||
// Get the page title if available from the extension context
|
||||
var pageTitle: String? = nil
|
||||
if let item = extensionContext?.inputItems.first as? NSExtensionItem {
|
||||
pageTitle = item.attributedContentText?.string ?? item.attributedTitle?.string
|
||||
}
|
||||
|
||||
// Create a structured format for URL sharing
|
||||
let urlData: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": pageTitle ?? url.host ?? "Shared Link"
|
||||
]
|
||||
|
||||
// Convert to JSON string
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: jsonString, type: "url")
|
||||
} else {
|
||||
// Fallback to simple URL
|
||||
saveToSharedDefaults(content: url.absoluteString, type: "url")
|
||||
}
|
||||
|
||||
openMainApp()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user