Skip to content

Instantly share code, notes, and snippets.

@gr36
Created February 7, 2025 15:16
Show Gist options
  • Select an option

  • Save gr36/86ef10372404def29bb3efa2ac4d8d9a to your computer and use it in GitHub Desktop.

Select an option

Save gr36/86ef10372404def29bb3efa2ac4d8d9a to your computer and use it in GitHub Desktop.
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: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", 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