mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 01:05:20 +00:00
Add iOS share extension with rich link previews
- Implement share extension to receive URLs from other apps - Display shared links with emoji indicator and rich preview cards - Add custom compact link preview with image, title, and domain - Configure app groups for data sharing between extension and main app - Handle URL detection and parsing in share extension - Update message formatting to show only emoji for shared links - Add proper entitlements and activation rules for share extension
This commit is contained in:
@@ -433,11 +433,16 @@ struct ContentView: View {
|
||||
|
||||
// Check for links and show preview
|
||||
if let markdownLink = message.content.extractMarkdownLink() {
|
||||
LinkPreviewView(url: markdownLink.url, title: markdownLink.title)
|
||||
.padding(.top, 4)
|
||||
// Don't show link preview if the message is just the emoji
|
||||
let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleanContent.hasPrefix("👇") {
|
||||
LinkPreviewView(url: markdownLink.url, title: markdownLink.title)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
} else {
|
||||
// Check for plain URLs
|
||||
let urls = message.content.extractURLs()
|
||||
let _ = urls.isEmpty ? nil : print("DEBUG: Found \(urls.count) plain URLs in message")
|
||||
ForEach(urls.prefix(3), id: \.url) { urlInfo in
|
||||
LinkPreviewView(url: urlInfo.url, title: nil)
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -15,8 +15,7 @@ struct LinkPreviewView: View {
|
||||
let url: URL
|
||||
let title: String?
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var isLoadingMetadata = false
|
||||
@State private var metadata: LinkMetadata?
|
||||
@State private var metadata: LPLinkMetadata?
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
@@ -31,6 +30,18 @@ struct LinkPreviewView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Always use our custom compact view for consistent appearance
|
||||
compactLinkView
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@State private var previewImage: UIImage? = nil
|
||||
#endif
|
||||
|
||||
private var compactLinkView: some View {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
@@ -38,71 +49,183 @@ struct LinkPreviewView: View {
|
||||
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)
|
||||
HStack(spacing: 12) {
|
||||
// Preview image or icon
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if let image = previewImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
// Favicon or default icon
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay(
|
||||
Image(systemName: "link")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(Color.blue)
|
||||
)
|
||||
}
|
||||
#else
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay(
|
||||
Image(systemName: "link")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(Color.blue)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
// 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))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title
|
||||
Text(metadata?.title ?? title ?? url.host ?? "Link")
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// Host
|
||||
Text(url.host ?? url.absoluteString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(backgroundColor.opacity(0.05))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
|
||||
private var simpleLinkView: some View {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}) {
|
||||
HStack(spacing: 12) {
|
||||
// Link icon
|
||||
Image(systemName: "link.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(Color.blue.opacity(0.8))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Title
|
||||
Text(title ?? url.host ?? "Link")
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// URL
|
||||
Text(url.absoluteString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Arrow indicator
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor.opacity(0.5))
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
#if os(iOS)
|
||||
guard metadata == nil && !isLoadingMetadata else { return }
|
||||
guard metadata == nil else { return }
|
||||
|
||||
isLoadingMetadata = true
|
||||
let provider = LPMetadataProvider()
|
||||
provider.startFetchingMetadata(for: url) { metadata, error in
|
||||
provider.startFetchingMetadata(for: url) { fetchedMetadata, 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
|
||||
)
|
||||
if let fetchedMetadata = fetchedMetadata {
|
||||
// Use the fetched metadata, or create new with our title
|
||||
if let title = self.title, !title.isEmpty {
|
||||
fetchedMetadata.title = title
|
||||
}
|
||||
self.metadata = fetchedMetadata
|
||||
|
||||
// Try to extract image
|
||||
if let imageProvider = fetchedMetadata.imageProvider {
|
||||
imageProvider.loadObject(ofClass: UIImage.self) { image, error in
|
||||
DispatchQueue.main.async {
|
||||
if let image = image as? UIImage {
|
||||
self.previewImage = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.isLoadingMetadata = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkMetadata {
|
||||
let title: String?
|
||||
let description: String?
|
||||
let imageURL: URL?
|
||||
#if os(iOS)
|
||||
// UIViewRepresentable wrapper for LPLinkView
|
||||
struct LinkPreview: UIViewRepresentable {
|
||||
let metadata: LPLinkMetadata
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let containerView = UIView()
|
||||
containerView.backgroundColor = .clear
|
||||
|
||||
let linkView = LPLinkView(metadata: metadata)
|
||||
linkView.isUserInteractionEnabled = false // We handle taps at the SwiftUI level
|
||||
linkView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
containerView.addSubview(linkView)
|
||||
NSLayoutConstraint.activate([
|
||||
linkView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
linkView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
linkView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
linkView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
|
||||
return containerView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
// Update if needed
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Helper to extract URLs from text
|
||||
extension String {
|
||||
@@ -130,7 +253,7 @@ extension String {
|
||||
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) }
|
||||
let isPartOfMarkdown = urls.contains { $0.1.overlaps(range) }
|
||||
if !isPartOfMarkdown {
|
||||
urls.append((url, range))
|
||||
}
|
||||
@@ -142,15 +265,19 @@ extension String {
|
||||
}
|
||||
|
||||
func extractMarkdownLink() -> (title: String, url: URL)? {
|
||||
print("DEBUG: Checking for markdown link in: \(self)")
|
||||
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)
|
||||
let result = (String(self[titleRange]), url)
|
||||
print("DEBUG: Found markdown link - title: \(result.0), url: \(result.1)")
|
||||
return result
|
||||
}
|
||||
}
|
||||
print("DEBUG: No markdown link found")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user