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:
jack
2025-07-08 17:47:53 +02:00
parent b8d930614e
commit d5ccf6bc0b
9 changed files with 369 additions and 118 deletions
+16 -4
View File
@@ -58,19 +58,24 @@ struct BitchatApp: App {
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
print("Failed to access app group UserDefaults")
print("DEBUG: Failed to access app group UserDefaults")
return
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
print("No shared content found in UserDefaults")
print("DEBUG: No shared content found in UserDefaults")
return
}
print("DEBUG: Found shared content: \(sharedContent)")
print("DEBUG: Shared date: \(sharedDate)")
print("DEBUG: Time since shared: \(Date().timeIntervalSince(sharedDate)) seconds")
// Only process if shared within last 30 seconds
if Date().timeIntervalSince(sharedDate) < 30 {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
print("DEBUG: Content type: \(contentType)")
// Clear the shared content
userDefaults.removeObject(forKey: "sharedContent")
@@ -93,21 +98,28 @@ struct BitchatApp: App {
// Send the shared content after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if contentType == "url" {
print("DEBUG: Processing URL content")
// 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))")
// Send just emoji with hidden markdown link
let markdownLink = "👇 [\(title)](\(url))"
print("DEBUG: Sending markdown link: \(markdownLink)")
self.chatViewModel.sendMessage(markdownLink)
} else {
// Fallback to simple URL
print("DEBUG: Failed to parse JSON, sending as plain URL")
self.chatViewModel.sendMessage("Shared link: \(sharedContent)")
}
} else {
print("DEBUG: Sending plain text: \(sharedContent)")
self.chatViewModel.sendMessage(sharedContent)
}
}
} else {
print("DEBUG: Shared content is too old, ignoring")
}
}
}
+9
View File
@@ -40,5 +40,14 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>bitchat</string>
</array>
</dict>
</array>
</dict>
</plist>
+28 -2
View File
@@ -1498,8 +1498,34 @@ class ChatViewModel: ObservableObject {
senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
// Process content with hashtags and mentions
let content = message.content
// Process content with hashtags, mentions, and markdown links
var content = message.content
// First, check if content starts with 👇 followed by markdown link
if content.hasPrefix("👇 [") {
// This is a URL share - remove everything after the emoji
if let linkStart = content.firstIndex(of: "[") {
let indexBeforeLink = content.index(before: linkStart)
content = String(content[..<indexBeforeLink])
}
} else {
// Handle normal markdown links
let markdownLinkPattern = #"\[([^\]]+)\]\(([^)]+)\)"#
if let markdownRegex = try? NSRegularExpression(pattern: markdownLinkPattern, options: []) {
let markdownMatches = markdownRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
// Process matches in reverse order to maintain string indices
for match in markdownMatches.reversed() {
if let fullRange = Range(match.range, in: content),
let titleRange = Range(match.range(at: 1), in: content) {
// Normal markdown link - replace with just the title
let linkTitle = String(content[titleRange])
content.replaceSubrange(fullRange, with: linkTitle)
}
}
}
}
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionPattern = "@([a-zA-Z0-9_]+)"
+7 -2
View File
@@ -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)
+168 -41
View File
@@ -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
}
}