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:
jack
2025-07-08 04:32:57 +02:00
parent 244af2197c
commit b8d930614e
4 changed files with 225 additions and 16 deletions
+15 -2
View File
@@ -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)
}
}
}
}
+26 -11
View File
@@ -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)
}
}
}
}
+163
View File
@@ -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()
}