diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 4bfa649d..2e291d21 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -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) + } } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 23e21ed8..6f95d07c 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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) + } } } } diff --git a/bitchat/Views/LinkPreviewView.swift b/bitchat/Views/LinkPreviewView.swift new file mode 100644 index 00000000..79b5c9aa --- /dev/null +++ b/bitchat/Views/LinkPreviewView.swift @@ -0,0 +1,163 @@ +// +// LinkPreviewView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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)] { + var urls: [(URL, Range)] = [] + + // 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() + } +} \ No newline at end of file diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index 27a65c84..941f0f86 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -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() }