diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 9ab4d457..a958fa47 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -628,6 +628,7 @@ 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; }; 7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; }; 7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; + K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; 7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFF6316299FEFE5005D382A /* SelectableText.swift */; }; 82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D6FA992CD9820500C925F4 /* ShareViewController.swift */; }; 82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -746,6 +747,7 @@ 82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; }; 82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; }; 82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; + FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; }; 82D6FB332CD99F7900C925F4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; 82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; @@ -1361,6 +1363,7 @@ D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; }; D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; }; D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; }; + 3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; }; D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; }; D73E5E672C6A97F4007EB227 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; @@ -2686,6 +2689,7 @@ 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = ""; }; 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = ""; }; 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = ""; }; + BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = ""; }; 7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = ""; }; 82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -5103,6 +5107,7 @@ isa = PBXGroup; children = ( 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */, + BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */, 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */, D72E12772BEED22400F4F781 /* Array.swift */, D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */, @@ -5951,6 +5956,7 @@ 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */, 4C2B7BF22A71B6540049DEE7 /* Id.swift in Sources */, 7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */, + K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */, 4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */, 4C463CBF2B960B96008A8C36 /* PurpleBackdrop.swift in Sources */, 5C8F97502EBD704A009399B1 /* LabsToggleView.swift in Sources */, @@ -6510,6 +6516,7 @@ 82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */, 5C8F970B2EB45E8C009399B1 /* LiveChatModel.swift in Sources */, 82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */, + FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */, 5C8F972F2EB46116009399B1 /* LiveStreamStatus.swift in Sources */, D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */, 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */, @@ -6990,6 +6997,7 @@ D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */, D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */, D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */, + 3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */, D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */, D73E5E672C6A97F4007EB227 /* Array.swift in Sources */, D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */, diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index c766ab64..9b8b6a80 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -382,10 +382,176 @@ struct LongformContent { let words: Int init(_ markdown: String) { - let blocks = [BlockNode].init(markdown: markdown) + // Pre-process markdown to ensure images are block-level (have blank lines around them) + // This prevents images from being parsed as inline within text paragraphs + let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown) + let blocks = [BlockNode].init(markdown: processedMarkdown) self.markdown = MarkdownContent(blocks: blocks) self.words = count_markdown_words(blocks: blocks) } + + /// Ensures markdown images have blank lines around them so they parse as block-level elements. + /// Without blank lines, images followed by text are parsed as inline within a paragraph, + /// causing them to be clipped by SwiftUI's Text view. + /// + /// Safety: This function excludes: + /// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code + /// - Images inside lists (lines starting with -, *, + followed by space) + /// - Images inside blockquotes (lines starting with >) + /// - Images inside tables (lines containing | with table structure) + /// + /// Known limitations (images remain inline, may clip if mixed with text): + /// - Multi-line list items with images on continuation lines + /// - Reference-style images: ![alt][id] + /// - HTML tags + /// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters) + private static func ensureBlockLevelImages(_ markdown: String) -> String { + // First, identify regions to exclude (fenced code blocks and inline code) + let excludedRanges = findExcludedRanges(in: markdown) + + // Pattern matches markdown images: ![alt](url) or ![alt](url "title") + // Handles URLs with parentheses by matching balanced parens or escaped parens + let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"# + guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else { + return markdown + } + + var result = markdown + let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result)) + + // Process matches in reverse order to preserve indices + for match in matches.reversed() { + guard let range = Range(match.range, in: result) else { continue } + + // Skip if this match is inside an excluded region (code block/inline code) + // Use String.Index comparison for correctness with non-ASCII content + if excludedRanges.contains(where: { $0.overlaps(range) }) { + continue + } + + // Skip if inside a list, blockquote, or table context + if isInStructuredContext(result, at: range.lowerBound) { + continue + } + + let imageMarkdown = String(result[range]) + + // Check what's before the image + let beforeIndex = range.lowerBound + let hasParagraphBreakBefore = beforeIndex == result.startIndex || + result[result.index(before: beforeIndex)] == "\n" && ( + beforeIndex == result.index(after: result.startIndex) || + result[result.index(beforeIndex, offsetBy: -2)] == "\n" + ) + + // Check what's after the image + let afterIndex = range.upperBound + let hasParagraphBreakAfter = afterIndex == result.endIndex || + result[afterIndex] == "\n" && ( + result.index(after: afterIndex) == result.endIndex || + result[result.index(after: afterIndex)] == "\n" + ) + + // Build replacement with proper paragraph breaks + var replacement = imageMarkdown + if !hasParagraphBreakBefore && beforeIndex != result.startIndex { + replacement = "\n\n" + replacement + } + if !hasParagraphBreakAfter && afterIndex != result.endIndex { + replacement = replacement + "\n\n" + } + + if replacement != imageMarkdown { + result.replaceSubrange(range, with: replacement) + } + } + + return result + } + + /// Checks if the position is inside a list, blockquote, or table context. + /// Returns true if modifying this position would break markdown structure. + private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool { + // Find the start of the current line + var lineStart = position + while lineStart > markdown.startIndex { + let prevIndex = markdown.index(before: lineStart) + if markdown[prevIndex] == "\n" { + break + } + lineStart = prevIndex + } + + // Get the line prefix (content before the image on this line) + let linePrefix = String(markdown[lineStart.. + if trimmedPrefix.hasPrefix(">") { + return true + } + + // Check for table context: line has | at both start area and elsewhere (actual table row) + var lineEnd = position + while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" { + lineEnd = markdown.index(after: lineEnd) + } + let fullLine = String(markdown[lineStart..= 2 { + return true + } + + return false + } + + /// Finds ranges in the markdown that should be excluded from image processing. + /// Returns ranges as String.Index for correct handling of non-ASCII content. + private static func findExcludedRanges(in markdown: String) -> [Range] { + var ranges: [Range] = [] + let fullRange = NSRange(markdown.startIndex..., in: markdown) + + // Find fenced code blocks (3+ backticks or tildes) + let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"# + guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else { + return ranges + } + ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange) + .compactMap { Range($0.range, in: markdown) }) + + // Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line) + // Blank line may contain whitespace: \n followed by optional spaces/tabs then \n + let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?: |\t).+\n?)+)"# + guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else { + return ranges + } + ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange) + .filter { $0.numberOfRanges > 1 } + .compactMap { Range($0.range(at: 1), in: markdown) }) + + // Find inline code (1+ backticks, matching pairs) + let inlinePattern = #"(`+)(?!`)[^`]*?\1"# + guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else { + return ranges + } + ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange) + .compactMap { Range($0.range, in: markdown) }) + + return ranges + } } func count_markdown_words(blocks: [BlockNode]) -> Int { diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift index 3ee4c932..8ed3bec2 100644 --- a/damus/Features/Events/NoteContentView.swift +++ b/damus/Features/Events/NoteContentView.swift @@ -361,7 +361,10 @@ struct NoteContentView: View { Group { switch self.note_artifacts { case .longform(let md): + // Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding Markdown(md.markdown) + .markdownImageProvider(.kingfisher(disable_animation: damus_state.settings.disable_animation)) + .markdownInlineImageProvider(.kingfisher) .padding([.leading, .trailing, .top]) case .separated(let separated): if #available(iOS 17.4, macOS 14.4, *) { @@ -369,12 +372,13 @@ struct NoteContentView: View { #if !targetEnvironment(macCatalyst) .translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair)) #endif + .fixedSize(horizontal: false, vertical: true) } else { MainContent(artifacts: separated) + .fixedSize(horizontal: false, vertical: true) } } } - .fixedSize(horizontal: false, vertical: true) } var normalizedHighlightTerms: [String] { diff --git a/damus/Features/Longform/Views/LongformView.swift b/damus/Features/Longform/Views/LongformView.swift index dee83875..193e20c9 100644 --- a/damus/Features/Longform/Views/LongformView.swift +++ b/damus/Features/Longform/Views/LongformView.swift @@ -11,13 +11,13 @@ struct LongformView: View { let state: DamusState let event: LongformEvent @ObservedObject var artifacts: NoteArtifactsModel - + init(state: DamusState, event: LongformEvent, artifacts: NoteArtifactsModel? = nil) { self.state = state self.event = event self._artifacts = ObservedObject(wrappedValue: artifacts ?? state.events.get_cache_data(event.event.id).artifacts_model) } - + var options: EventViewOptions { return [.wide, .no_mentions, .no_replying_to] } diff --git a/damus/Shared/Extensions/KingfisherImageProvider.swift b/damus/Shared/Extensions/KingfisherImageProvider.swift new file mode 100644 index 00000000..87de41b5 --- /dev/null +++ b/damus/Shared/Extensions/KingfisherImageProvider.swift @@ -0,0 +1,98 @@ +// +// KingfisherImageProvider.swift +// damus +// +// Created by alltheseas on 2026-01-03. +// + +import SwiftUI +import Kingfisher +import MarkdownUI + +/// A custom image provider for MarkdownUI that uses Kingfisher for image loading. +/// This provides proper aspect ratio handling and caching for images in longform markdown content. +struct KingfisherImageProvider: ImageProvider { + let disable_animation: Bool + + init(disable_animation: Bool = false) { + self.disable_animation = disable_animation + } + + func makeImage(url: URL?) -> some View { + KingfisherMarkdownImage(url: url, disable_animation: disable_animation) + } +} + +extension ImageProvider where Self == KingfisherImageProvider { + /// A Kingfisher-based image provider for loading images with proper caching and aspect ratio handling. + static var kingfisher: Self { .init() } + + /// A Kingfisher-based image provider with animation disabled. + static func kingfisher(disable_animation: Bool) -> Self { + .init(disable_animation: disable_animation) + } +} + +// MARK: - InlineImageProvider (for images mixed with text) + +/// A custom inline image provider for MarkdownUI that uses Kingfisher for loading inline images. +/// This handles images that appear within text content (not standalone image paragraphs). +struct KingfisherInlineImageProvider: InlineImageProvider { + func image(with url: URL, label: String) async throws -> Image { + return try await withCheckedThrowingContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url) { result in + switch result { + case .success(let imageResult): + continuation.resume(returning: Image(uiImage: imageResult.image)) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } +} + +extension InlineImageProvider where Self == KingfisherInlineImageProvider { + /// A Kingfisher-based inline image provider for loading images within text. + static var kingfisher: Self { .init() } +} + +// MARK: - ImageProvider View (for standalone image paragraphs) + +/// Internal view that handles the actual Kingfisher image loading for markdown content. +/// Uses state to track loaded image dimensions for proper aspect ratio sizing. +private struct KingfisherMarkdownImage: View { + let url: URL? + let disable_animation: Bool + @State private var imageSize: CGSize? + + /// Returns a valid aspect ratio, guarding against zero/invalid dimensions. + private var safeAspectRatio: CGSize { + guard let size = imageSize, size.width > 0, size.height > 0 else { + return CGSize(width: 1, height: 1) + } + return size + } + + var body: some View { + if let url { + KFAnimatedImage(url) + .callbackQueue(.dispatch(.global(qos: .background))) + .backgroundDecode(true) + .imageContext(.note, disable_animation: disable_animation) + .image_fade(duration: 0.25) + .cancelOnDisappear(true) + .configure { view in + view.framePreloadCount = 3 + } + .observe_image_size { size in + imageSize = size + } + .aspectRatio(safeAspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + .kfClickable() + } else { + EmptyView() + } + } +}