Created
February 7, 2025 15:16
-
-
Save gr36/86ef10372404def29bb3efa2ac4d8d9a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| struct PostContent: View { | |
| @Environment(\.openURL) private var openURL | |
| @EnvironmentObject private var appearanceManager: AppearanceManager | |
| @EnvironmentObject private var videoManager: VideoPlayerManager | |
| let content: String | |
| @State private var showingProfile = false | |
| @State private var selectedUsername: String? | |
| @State private var showingShareSheet = false | |
| @State private var urlToShare: URL? | |
| @State private var showingBrowser = false | |
| @State private var urlToOpen: URL? | |
| @State private var selectedImage: URL? = nil | |
| @State private var selectedImageFrame: CGRect = .zero | |
| @State private var selectedVideo: URL? = nil | |
| @State private var thumbnailImage: UIImage? | |
| private struct MediaItem { | |
| let url: URL | |
| let posterURL: URL? | |
| let isVideo: Bool | |
| } | |
| var body: some View { | |
| let (text, links, quotes, mediaItems) = parseContent(content) | |
| VStack(alignment: .leading, spacing: 12) { | |
| if !mediaItems.isEmpty { | |
| if mediaItems.count == 1 { | |
| // Single media item | |
| let item = mediaItems[0] | |
| if item.isVideo { | |
| AsyncImage(url: item.posterURL ?? item.url) { phase in | |
| switch phase { | |
| case .success(let image): | |
| image | |
| .resizable() | |
| .aspectRatio(contentMode: .fill) | |
| .frame(maxWidth: .infinity, maxHeight: 280) | |
| .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| .padding(.horizontal, -8) | |
| .layoutPriority(1) | |
| .overlay { | |
| Image(systemName: "play.circle.fill") | |
| .font(.system(size: 50)) | |
| .foregroundStyle(.white) | |
| .shadow(radius: 8) | |
| } | |
| .onTapGesture { | |
| selectedVideo = item.url | |
| } | |
| case .failure, .empty: | |
| if let thumbnail = thumbnailImage { | |
| Image(uiImage: thumbnail) | |
| .resizable() | |
| .aspectRatio(contentMode: .fill) | |
| .frame(maxWidth: .infinity, maxHeight: 280) | |
| .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| .padding(.horizontal, -8) | |
| .layoutPriority(1) | |
| .overlay { | |
| Image(systemName: "play.circle.fill") | |
| .font(.system(size: 50)) | |
| .foregroundStyle(.white) | |
| .shadow(radius: 8) | |
| } | |
| .onTapGesture { | |
| selectedVideo = item.url | |
| } | |
| } else { | |
| Color.gray | |
| .frame(maxWidth: .infinity, maxHeight: 280) | |
| .overlay { | |
| Image(systemName: "play.circle.fill") | |
| .font(.system(size: 50)) | |
| .foregroundStyle(.white) | |
| .shadow(radius: 8) | |
| } | |
| .onAppear { | |
| loadThumbnail(for: item.url) | |
| } | |
| } | |
| @unknown default: | |
| Color.gray | |
| .frame(maxWidth: .infinity, maxHeight: 280) | |
| } | |
| } | |
| .cornerRadius(12) | |
| .onAppear { | |
| debugPrintMediaItems(mediaItems) | |
| } | |
| } else { | |
| AdaptiveImageView( | |
| url: item.url, | |
| maxHeight: 280 | |
| ) { | |
| selectedImage = item.url | |
| } | |
| } | |
| } else { | |
| // Multiple items - use carousel | |
| ImageCarousel(urls: mediaItems.map { $0.posterURL ?? $0.url }) { url in | |
| if let item = mediaItems.first(where: { $0.posterURL == url || $0.url == url }) { | |
| AdaptiveImageView( | |
| url: url, | |
| maxHeight: 280 | |
| ) { | |
| if item.isVideo { | |
| selectedVideo = item.url | |
| } else { | |
| selectedImage = item.url | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Text with links and quotes | |
| ForEach(Array(zip(text.indices, text)), id: \.0) { index, segment in | |
| if quotes[index] { | |
| // Blockquote styling | |
| Text(attributedString(text: segment, links: links)) | |
| .padding(.leading, 12) | |
| .padding(.vertical, 4) | |
| .overlay(alignment: .leading) { | |
| Rectangle() | |
| .fill(appearanceManager.accentColor.opacity(0.5)) | |
| .frame(width: 4) | |
| } | |
| } else { | |
| Text(attributedString(text: segment, links: links)) | |
| } | |
| } | |
| } | |
| .padding(.horizontal, 0) | |
| .environment(\.openURL, OpenURLAction { url in | |
| if url.host == "micro.blog" { | |
| let username = url.lastPathComponent | |
| if !username.isEmpty { | |
| selectedUsername = username | |
| showingProfile = true | |
| return .handled | |
| } | |
| } else { | |
| // Open other URLs in SafariView | |
| urlToOpen = url | |
| showingBrowser = true | |
| return .handled | |
| } | |
| return .systemAction | |
| }) | |
| .sheet(isPresented: $showingProfile) { | |
| if let username = selectedUsername { | |
| NavigationStack { | |
| UserProfileView( | |
| username: username, | |
| onError: { _ in }, | |
| isOwnProfile: false, | |
| isSheetPresentation: true | |
| ) | |
| } | |
| } | |
| } | |
| .sheet(isPresented: $showingBrowser) { | |
| if let url = urlToOpen { | |
| SafariView(url: url) | |
| } | |
| } | |
| .onChange(of: selectedVideo) { _, videoURL in | |
| if let url = videoURL { | |
| videoManager.play(url) | |
| selectedVideo = nil | |
| } | |
| } | |
| .fullScreenCover(item: $selectedImage) { imageURL in | |
| ImageLightboxView(imageURL: imageURL) | |
| } | |
| .sheet(isPresented: $showingShareSheet) { | |
| if let url = urlToShare { | |
| ShareSheet(items: [url]) | |
| } | |
| } | |
| } | |
| private func attributedString(text: String, links: [Link]) -> AttributedString { | |
| var attributedString = AttributedString(text) | |
| for link in links { | |
| if let range = text.range(of: link.text) { | |
| let attributedRange = Range(range, in: attributedString)! | |
| attributedString[attributedRange].link = link.url | |
| attributedString[attributedRange].foregroundColor = appearanceManager.accentColor | |
| } | |
| } | |
| return attributedString | |
| } | |
| private func parseContent(_ html: String) -> (text: [String], links: [Link], quotes: [Bool], media: [MediaItem]) { | |
| var cleanContent = html | |
| var links: [Link] = [] | |
| var media: [MediaItem] = [] | |
| var segments: [String] = [] | |
| var isQuote: [Bool] = [] | |
| // Update media extraction to handle both images and videos | |
| let mediaPattern = #"<(img|video)[^>]+(?:src|href)="([^"]+)"[^>]*>"# | |
| if let regex = try? NSRegularExpression(pattern: mediaPattern) { | |
| let nsString = cleanContent as NSString | |
| let matches = regex.matches(in: cleanContent, range: NSRange(location: 0, length: nsString.length)) | |
| for match in matches.reversed() { | |
| let typeRange = match.range(at: 1) | |
| let urlRange = match.range(at: 2) | |
| let type = nsString.substring(with: typeRange) | |
| let urlString = nsString.substring(with: urlRange) | |
| if let url = URL(string: urlString) { | |
| if type == "video" { | |
| // For videos, try to get the poster image | |
| let posterPattern = #"poster="([^"]+)""# | |
| if let posterRegex = try? NSRegularExpression(pattern: posterPattern), | |
| let posterMatch = posterRegex.firstMatch(in: cleanContent, range: NSRange(location: 0, length: nsString.length)), | |
| let posterRange = Range(posterMatch.range(at: 1), in: cleanContent), | |
| let posterURL = URL(string: String(cleanContent[posterRange])) { | |
| media.append(MediaItem(url: url, posterURL: posterURL, isVideo: true)) | |
| } else { | |
| media.append(MediaItem(url: url, posterURL: nil, isVideo: true)) | |
| } | |
| } else { | |
| media.append(MediaItem(url: url, posterURL: nil, isVideo: false)) | |
| } | |
| } | |
| cleanContent = (cleanContent as NSString).replacingCharacters( | |
| in: match.range, | |
| with: "" | |
| ) | |
| } | |
| } | |
| // Add direct MP4 URL detection | |
| let mp4Pattern = #"https?://[^\s<>"]+?\.mp4"# | |
| if let regex = try? NSRegularExpression(pattern: mp4Pattern) { | |
| let nsString = cleanContent as NSString | |
| let matches = regex.matches(in: cleanContent, range: NSRange(location: 0, length: nsString.length)) | |
| for match in matches.reversed() { | |
| let urlString = nsString.substring(with: match.range) | |
| if let url = URL(string: urlString) { | |
| print("📹 Found MP4 URL: \(url)") | |
| let posterURL = URL(string: "https://micro.blog/assets/images/video-thumbnail.jpg") | |
| print("🖼️ Using poster URL: \(String(describing: posterURL))") | |
| media.append(MediaItem( | |
| url: url, | |
| posterURL: nil, // Will be handled by AsyncImage | |
| isVideo: true | |
| )) | |
| print("📦 Media items count: \(media.count)") | |
| } | |
| cleanContent = (cleanContent as NSString).replacingCharacters( | |
| in: match.range, | |
| with: "" | |
| ) | |
| } | |
| } | |
| // Extract blockquotes | |
| let blockquotePattern = #"<blockquote>(.*?)</blockquote>"# | |
| if let regex = try? NSRegularExpression(pattern: blockquotePattern, options: .dotMatchesLineSeparators) { | |
| let nsString = cleanContent as NSString | |
| var lastIndex = 0 | |
| let matches = regex.matches(in: cleanContent, range: NSRange(location: 0, length: nsString.length)) | |
| for match in matches { | |
| // Add text before quote if any | |
| if match.range.location > lastIndex { | |
| let normalText = nsString.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex)) | |
| if !normalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| segments.append(normalText) | |
| isQuote.append(false) | |
| } | |
| } | |
| // Add quote text | |
| let quoteRange = match.range(at: 1) | |
| let quoteText = nsString.substring(with: quoteRange) | |
| segments.append(quoteText) | |
| isQuote.append(true) | |
| lastIndex = match.range.location + match.range.length | |
| } | |
| // Add remaining text if any | |
| if lastIndex < nsString.length { | |
| let remainingText = nsString.substring(from: lastIndex) | |
| if !remainingText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| segments.append(remainingText) | |
| isQuote.append(false) | |
| } | |
| } | |
| } else { | |
| segments = [cleanContent] | |
| isQuote = [false] | |
| } | |
| // Process each segment | |
| segments = segments.map { segment in | |
| var cleanSegment = segment | |
| // Extract HTML links | |
| let linkPattern = #"<a href="([^"]+)"[^>]*>([^<]+)</a>"# | |
| if let regex = try? NSRegularExpression(pattern: linkPattern) { | |
| let nsString = cleanSegment as NSString | |
| let matches = regex.matches(in: cleanSegment, range: NSRange(location: 0, length: nsString.length)) | |
| for match in matches.reversed() { | |
| let urlRange = match.range(at: 1) | |
| let textRange = match.range(at: 2) | |
| let urlString = nsString.substring(with: urlRange) | |
| let linkText = nsString.substring(with: textRange) | |
| if let url = URL(string: urlString) { | |
| links.append(Link(text: linkText, url: url)) | |
| cleanSegment = (cleanSegment as NSString).replacingCharacters( | |
| in: match.range, | |
| with: linkText | |
| ) | |
| } | |
| } | |
| } | |
| // Clean up HTML entities and tags | |
| cleanSegment = cleanSegment | |
| .replacingOccurrences(of: "<br>", with: "\n") | |
| .replacingOccurrences(of: "<br/>", with: "\n") | |
| .replacingOccurrences(of: "<br />", with: "\n") | |
| .replacingOccurrences(of: "<p>", with: "") | |
| .replacingOccurrences(of: "</p>", with: "\n") | |
| .replacingOccurrences(of: """, with: "\"") | |
| .replacingOccurrences(of: "'", with: "'") | |
| .replacingOccurrences(of: "&", with: "&") | |
| .replacingOccurrences(of: "<", with: "<") | |
| .replacingOccurrences(of: ">", with: ">") | |
| // Remove any remaining HTML tags | |
| if let regex = try? NSRegularExpression(pattern: "<[^>]+>", options: []) { | |
| cleanSegment = regex.stringByReplacingMatches( | |
| in: cleanSegment, | |
| range: NSRange(cleanSegment.startIndex..., in: cleanSegment), | |
| withTemplate: "" | |
| ) | |
| } | |
| return cleanSegment.trimmingCharacters(in: .whitespacesAndNewlines) | |
| } | |
| // Detect plain URLs in cleaned text | |
| for (_, segment) in segments.enumerated() { | |
| let urlPattern = #"https?://[^\s<>\"]+(?![^<>]*>|[^<>]*</)"# | |
| if let regex = try? NSRegularExpression(pattern: urlPattern) { | |
| let nsString = segment as NSString | |
| let matches = regex.matches(in: segment, range: NSRange(location: 0, length: nsString.length)) | |
| for match in matches { | |
| let urlString = nsString.substring(with: match.range) | |
| .trimmingCharacters(in: .punctuationCharacters) | |
| if let url = URL(string: urlString) { | |
| links.append(Link(text: urlString, url: url)) | |
| } | |
| } | |
| } | |
| } | |
| return (segments, links, isQuote, media) | |
| } | |
| private func generateThumbnailURL(for videoURL: URL) -> URL? { | |
| // Try to use overcast's thumbnail service if it's an overcast URL | |
| if videoURL.host?.contains("overcast.fm") == true { | |
| let urlString = videoURL.absoluteString | |
| if let encodedURL = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { | |
| return URL(string: "https://micro.blog/video/poster?url=\(encodedURL)") | |
| } | |
| } | |
| // For other services, try to use their thumbnail if available | |
| if videoURL.host?.contains("youtube.com") == true || | |
| videoURL.host?.contains("youtu.be") == true { | |
| // Extract video ID and use YouTube thumbnail | |
| let videoId = videoURL.lastPathComponent | |
| return URL(string: "https://img.youtube.com/vi/\(videoId)/0.jpg") | |
| } | |
| // For other MP4s, use a generic video thumbnail with the domain | |
| if let host = videoURL.host { | |
| return URL(string: "https://micro.blog/video/poster?domain=\(host)") | |
| } | |
| return nil | |
| } | |
| private func getImageFrame(for url: URL) -> CGRect? { | |
| // Get the frame of the tapped image | |
| // This would need to be implemented to track the frame of each image | |
| // You might need to use a GeometryReader or UIKit bridge | |
| return .zero // For now, just return zero rect | |
| } | |
| private func extractVideoURL(from content: String) -> URL? { | |
| // First check for direct MP4 URL | |
| if content.contains(".mp4") { | |
| let pattern = #"https?://[^\s<>"]+?\.mp4"# | |
| if let range = content.range(of: pattern, options: .regularExpression), | |
| let url = URL(string: String(content[range])) { | |
| return url | |
| } | |
| } | |
| // Then check for HTML video tag | |
| let pattern = #"<video[^>]+src="([^"]+)"[^>]*>"# | |
| if let regex = try? NSRegularExpression(pattern: pattern), | |
| let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), | |
| let srcRange = Range(match.range(at: 1), in: content) { | |
| let urlString = String(content[srcRange]) | |
| return URL(string: urlString) | |
| } | |
| return nil | |
| } | |
| private func debugPrintMediaItems(_ items: [MediaItem]) { | |
| for (index, item) in items.enumerated() { | |
| print("🖼️ Media item \(index + 1):") | |
| print(" URL: \(item.url)") | |
| print(" Poster URL: \(String(describing: item.posterURL))") | |
| print(" Is Video: \(item.isVideo)") | |
| } | |
| } | |
| private func loadThumbnail(for url: URL) { | |
| Task { | |
| if let thumbnail = await VideoThumbnailService.shared.getThumbnail(for: url) { | |
| await MainActor.run { | |
| thumbnailImage = thumbnail | |
| } | |
| } | |
| } | |
| } | |
| private struct Link: Identifiable { | |
| let id = UUID() | |
| let text: String | |
| let url: URL | |
| } | |
| } | |
| struct ShareSheet: UIViewControllerRepresentable { | |
| let items: [Any] | |
| func makeUIViewController(context: Context) -> UIActivityViewController { | |
| UIActivityViewController(activityItems: items, applicationActivities: nil) | |
| } | |
| func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment