diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 7ac8d3ff916b..5ca6e492778f 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -5,6 +5,7 @@ * [*] [internal] Stop donating screen activities as Siri predictions now that App Shortcuts cover them [#25757] * [*] Fix an issue where the Reader tab and Me tab show incorrect state after logging out [#25952] * [*] Stats: Open the latest post when tapping the Latest Post Summary card in the Insights tab [#25896] +* [*] Share extension: fix text being dropped when a note containing both text and images is shared [#25987] 27.2 ----- diff --git a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift index 7751abe9e767..6704f285f3ca 100644 --- a/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift +++ b/WordPress/WordPressShareExtension/Sources/Services/ShareExtractor.swift @@ -33,7 +33,7 @@ struct ExtractedShare { // Build the returned string by doing the following: // * 1: Look for imported text. - // * 2: Look for selected text, if it exists put it into a blockquote. + // * 2: Look for selected text, quoting it only when there is a source to attribute. // * 3: No selected text, but we have a page description...use that. // * 4: No selected text, but we have a page title...use that. // * Finally, default to a simple link if nothing else is found @@ -42,7 +42,13 @@ struct ExtractedShare { } guard selectedText.isEmpty else { - return "

\(selectedText.escapeHtmlNamedEntities())\(readOnText)

" + let paragraphs = ExtractedShare.paragraphsHTML(from: selectedText, appending: readOnText) + + // Only quote text that arrived with a source to attribute. + guard url != nil else { + return paragraphs + } + return "
\(paragraphs)
" } if !description.isEmpty { @@ -53,6 +59,38 @@ struct ExtractedShare { return "

\(rawLink)

" } } + + /// Converts plain text to paragraphs, treating a blank line as a paragraph break and a single + /// line break as a `
`. `suffix` is appended to the last paragraph. + private static func paragraphsHTML(from text: String, appending suffix: String) -> String { + var paragraphs = [[String]]() + var current = [String]() + + for line in text.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") { + if line.trimmingCharacters(in: .whitespaces).isEmpty { + if !current.isEmpty { + paragraphs.append(current) + current = [] + } + } else { + current.append(line) + } + } + + if !current.isEmpty { + paragraphs.append(current) + } + + guard !paragraphs.isEmpty else { + return "

\(text.escapeHtmlNamedEntities())\(suffix)

" + } + + return paragraphs.enumerated().map { index, lines in + let body = lines.map { $0.escapeHtmlNamedEntities() }.joined(separator: "
") + let tail = index == paragraphs.count - 1 ? suffix : "" + return "

\(body)\(tail)

" + }.joined() + } } struct ExtractedImage { @@ -231,6 +269,10 @@ private protocol ExtensionContentExtractor { private protocol TypeBasedExtensionContentExtractor: ExtensionContentExtractor, Sendable { associatedtype Payload var acceptedType: String { get } + + /// The attachments this extractor will read. Defaults to everything matching `acceptedType`. + func itemProviders(in context: NSExtensionContext) -> [NSItemProvider] + func convert(payload: Payload) -> ExtractedItem? } @@ -243,12 +285,16 @@ private extension TypeBasedExtensionContentExtractor { return CGSize(width: dimension, height: dimension) } + func itemProviders(in context: NSExtensionContext) -> [NSItemProvider] { + return context.itemProviders(ofType: acceptedType) + } + func canHandle(context: NSExtensionContext) -> Bool { - return !context.itemProviders(ofType: acceptedType).isEmpty + return !itemProviders(in: context).isEmpty } func extract(context: NSExtensionContext, completion: @escaping ([ExtractedItem]) -> Void) { - let itemProviders = context.itemProviders(ofType: acceptedType) + let itemProviders = self.itemProviders(in: context) print(acceptedType) var results = [ExtractedItem]() guard !itemProviders.isEmpty else { @@ -340,6 +386,14 @@ private struct URLExtractor: TypeBasedExtensionContentExtractor { typealias Payload = URL let acceptedType = UTType.url.identifier + /// An image shared as a file also declares `public.url`, but `processLocalFile(url:)` cannot + /// convert it. Claiming it here would stop `PlainTextExtractor` reading the text beside it. + func itemProviders(in context: NSExtensionContext) -> [NSItemProvider] { + return context.itemProviders(ofType: acceptedType).filter { provider in + !provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + } + func convert(payload: URL) -> ExtractedItem? { guard !payload.isFileURL else { return processLocalFile(url: payload) diff --git a/WordPress/WordPressShareExtension/Sources/UI/ShareExtensionEditorViewController.swift b/WordPress/WordPressShareExtension/Sources/UI/ShareExtensionEditorViewController.swift index 116f37a11799..84885fdacec6 100644 --- a/WordPress/WordPressShareExtension/Sources/UI/ShareExtensionEditorViewController.swift +++ b/WordPress/WordPressShareExtension/Sources/UI/ShareExtensionEditorViewController.swift @@ -1008,7 +1008,10 @@ extension ShareExtensionEditorViewController { } func insertImageAttachment(with url: URL) { - let attachment = richTextView.replaceWithImage(at: self.richTextView.selectedRange, sourceURL: url, placeHolderImage: Assets.defaultMissingImage) + // `setHTML` leaves the caret at the start of the document, so append instead of inserting + // there, which would put shared images above the text they arrived with. + let endOfDocument = NSRange(location: richTextView.textStorage.length, length: 0) + let attachment = richTextView.replaceWithImage(at: endOfDocument, sourceURL: url, placeHolderImage: Assets.defaultMissingImage) attachment.size = .full attachment.uploadID = url.lastPathComponent // Use the filename as the uploadID here. @@ -1265,7 +1268,8 @@ private extension ShareExtensionEditorViewController { ShareExtractor(extensionContext: extensionContext) .loadShare { [weak self] share in self?.setTitleText(share.title) - self?.richTextView.setHTML(share.combinedContentHTML) + // The trailing paragraph keeps appended images off the end of the last sentence. + self?.richTextView.setHTML(share.combinedContentHTML + "

") share.images.forEach({ extractedImage in if extractedImage.insertionState == .requiresInsertion {