mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:05:19 +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
|
// Send the shared content after a short delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
let prefix = contentType == "url" ? "Shared link: " : ""
|
if contentType == "url" {
|
||||||
self.chatViewModel.sendMessage(prefix + sharedContent)
|
// 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)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
// Regular messages with natural text wrapping
|
// Regular messages with natural text wrapping
|
||||||
HStack(alignment: .top, spacing: 0) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// Single text view for natural wrapping
|
HStack(alignment: .top, spacing: 0) {
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
// Single text view for natural wrapping
|
||||||
.textSelection(.enabled)
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Delivery status indicator for private messages
|
// Delivery status indicator for private messages
|
||||||
if message.isPrivate && message.sender == viewModel.nickname,
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
let status = message.deliveryStatus {
|
let status = message.deliveryStatus {
|
||||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||||
.padding(.leading, 4)
|
.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) {
|
private func handleSharedURL(_ url: URL) {
|
||||||
// Convert URL to text and share
|
// Get the page title if available from the extension context
|
||||||
let text = url.absoluteString
|
var pageTitle: String? = nil
|
||||||
saveToSharedDefaults(content: text, type: "url")
|
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()
|
openMainApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user