diff --git a/Packages/Models/Sources/Models/Mention.swift b/Packages/Models/Sources/Models/Mention.swift index 588b3d8de..4ea106d7e 100644 --- a/Packages/Models/Sources/Models/Mention.swift +++ b/Packages/Models/Sources/Models/Mention.swift @@ -5,6 +5,13 @@ public struct Mention: Codable, Equatable, Hashable { public let username: String public let url: URL public let acct: String + + public init(id: String, username: String, url: URL, acct: String) { + self.id = id + self.username = username + self.url = url + self.acct = acct + } } extension Mention: Sendable {} diff --git a/Packages/StatusKit/.swiftpm/xcode/xcshareddata/xcschemes/StatusKitTests.xcscheme b/Packages/StatusKit/.swiftpm/xcode/xcshareddata/xcschemes/StatusKitTests.xcscheme new file mode 100644 index 000000000..4a6c571a8 --- /dev/null +++ b/Packages/StatusKit/.swiftpm/xcode/xcshareddata/xcschemes/StatusKitTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Packages/StatusKit/Package.swift b/Packages/StatusKit/Package.swift index 01d822297..197e705d0 100644 --- a/Packages/StatusKit/Package.swift +++ b/Packages/StatusKit/Package.swift @@ -40,6 +40,16 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v6) ] + ), + .testTarget( + name: "StatusKitTests", + dependencies: [ + "StatusKit", + .product(name: "Models", package: "Models"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] ) ] ) diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/EditorView.swift b/Packages/StatusKit/Sources/StatusKit/Editor/EditorView.swift index 646617c75..e979c0606 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/EditorView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/EditorView.swift @@ -121,7 +121,7 @@ extension StatusEditor { private var textInput: some View { TextView( - $viewModel.statusText, + viewModel.statusTextBinding, getTextView: { textView in viewModel.textView = textView } ) .placeholder( diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/TextService.swift b/Packages/StatusKit/Sources/StatusKit/Editor/TextService.swift new file mode 100644 index 000000000..cad7e4d45 --- /dev/null +++ b/Packages/StatusKit/Sources/StatusKit/Editor/TextService.swift @@ -0,0 +1,403 @@ +import DesignSystem +import Env +import Models +import SwiftUI +import UIKit + +extension StatusEditor { + @MainActor + struct TextService { + typealias Mode = StatusEditor.ViewModel.Mode + + struct InitialTextChanges { + var statusText: NSMutableAttributedString? + var selectedRange: NSRange? + var mentionString: String? + var spoilerOn: Bool? + var spoilerText: String? + var visibility: Models.Visibility? + var replyToStatus: Status? + var embeddedStatus: Status? + } + + struct TextUpdate { + var text: NSMutableAttributedString + var selection: NSRange + } + + struct TextProcessingResult: Equatable { + var urlLengthAdjustments: Int + var suggestionRange: NSRange? + var action: TextSuggestionAction + var didProcess: Bool + } + + enum TextSuggestionAction: Equatable { + case suggest(query: String) + case reset + case none + } + + private let maxLengthOfUrl = 23 + + func initialTextChanges( + for mode: Mode, + currentAccount: Account?, + currentInstance: CurrentInstance? + ) -> InitialTextChanges { + switch mode { + case .new(let text, let visibility): + if let text { + return InitialTextChanges( + statusText: .init(string: text), + selectedRange: trailingSelection(for: text), + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + } + return InitialTextChanges( + statusText: nil, + selectedRange: nil, + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + case .shareExtension: + return InitialTextChanges( + statusText: nil, + selectedRange: nil, + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: .pub, + replyToStatus: nil, + embeddedStatus: nil + ) + case .imageURL(_, let caption, _, let visibility): + if let caption, !caption.isEmpty { + return InitialTextChanges( + statusText: .init(string: caption), + selectedRange: trailingSelection(for: caption), + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + } + return InitialTextChanges( + statusText: nil, + selectedRange: nil, + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + case .replyTo(let status): + let mention = replyMentionText(for: status, currentAccount: currentAccount) + let trimmedMention = mention.isEmpty + ? nil + : mention.trimmingCharacters(in: .whitespaces) + return InitialTextChanges( + statusText: .init(string: mention), + selectedRange: trailingSelection(for: mention), + mentionString: trimmedMention, + spoilerOn: !status.spoilerText.asRawText.isEmpty, + spoilerText: status.spoilerText.asRawText, + visibility: UserPreferences.shared.getReplyVisibility(of: status), + replyToStatus: status, + embeddedStatus: nil + ) + case .mention(let account, let visibility): + let mention = "@\(account.acct) " + return InitialTextChanges( + statusText: .init(string: mention), + selectedRange: trailingSelection(for: mention), + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + case .edit(let status): + let normalizedText = editText(for: status) + return InitialTextChanges( + statusText: .init(string: normalizedText), + selectedRange: trailingSelection(for: normalizedText), + mentionString: nil, + spoilerOn: !status.spoilerText.asRawText.isEmpty, + spoilerText: status.spoilerText.asRawText, + visibility: status.visibility, + replyToStatus: nil, + embeddedStatus: nil + ) + case .quote(let status): + if currentInstance?.isQuoteSupported == true { + return InitialTextChanges( + statusText: nil, + selectedRange: nil, + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: nil, + replyToStatus: nil, + embeddedStatus: status + ) + } + let quoteText = legacyQuoteText(for: status) + guard !quoteText.isEmpty else { + return InitialTextChanges( + statusText: nil, + selectedRange: nil, + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: nil, + replyToStatus: nil, + embeddedStatus: status + ) + } + return InitialTextChanges( + statusText: .init(string: quoteText), + selectedRange: NSRange(location: 0, length: 0), + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: nil, + replyToStatus: nil, + embeddedStatus: status + ) + case .quoteLink(let link): + let text = "\n\n\(link)" + return InitialTextChanges( + statusText: .init(string: text), + selectedRange: NSRange(location: 0, length: 0), + mentionString: nil, + spoilerOn: nil, + spoilerText: nil, + visibility: nil, + replyToStatus: nil, + embeddedStatus: nil + ) + } + } + + func insertText( + _ text: String, + into statusText: NSMutableAttributedString, + selection: NSRange + ) -> TextUpdate { + let updatedText = NSMutableAttributedString(attributedString: statusText) + updatedText.mutableString.insert(text, at: selection.location) + let updatedSelection = NSRange(location: selection.location + text.utf16.count, length: 0) + return TextUpdate(text: updatedText, selection: updatedSelection) + } + + func replaceText( + with text: String, + in statusText: NSMutableAttributedString, + range: NSRange + ) -> TextUpdate { + let updatedText = NSMutableAttributedString(attributedString: statusText) + updatedText.mutableString.deleteCharacters(in: range) + updatedText.mutableString.insert(text, at: range.location) + let updatedSelection = NSRange(location: range.location + text.utf16.count, length: 0) + return TextUpdate(text: updatedText, selection: updatedSelection) + } + + func replaceText(with text: String) -> TextUpdate { + TextUpdate(text: .init(string: text), selection: trailingSelection(for: text)) + } + + func processText( + _ text: NSMutableAttributedString, + theme: Theme?, + selectedRange: NSRange, + hasMarkedText: Bool, + previousUrlLengthAdjustments: Int + ) -> TextProcessingResult { + guard !hasMarkedText else { + return TextProcessingResult( + urlLengthAdjustments: previousUrlLengthAdjustments, + suggestionRange: nil, + action: .none, + didProcess: false + ) + } + + applyBaseAttributes(to: text) + + let textValue = text.string + let allRange = NSRange(location: 0, length: textValue.utf16.count) + let ranges = hashtagRanges(in: textValue, range: allRange) + + mentionRanges(in: textValue, range: allRange) + + let tintColor = UIColor(theme?.tintColor ?? .brand) + applyHighlightAttributes(to: text, ranges: ranges, tintColor: tintColor) + + let suggestion = suggestionAction( + for: ranges, + text: textValue, + selectedRange: selectedRange + ) + + let urlRanges = urlRanges(in: textValue, range: allRange) + let urlLengthAdjustments = applyURLAttributes( + to: text, + ranges: urlRanges, + tintColor: tintColor + ) + + removeLinkAttributes(from: text, range: allRange) + + return TextProcessingResult( + urlLengthAdjustments: urlLengthAdjustments, + suggestionRange: suggestion.range, + action: suggestion.action, + didProcess: true + ) + } + + private func applyBaseAttributes(to text: NSMutableAttributedString) { + let range = NSRange(location: 0, length: text.string.utf16.count) + text.addAttributes( + [ + .foregroundColor: UIColor(Theme.shared.labelColor), + .font: Font.scaledBodyUIFont, + .backgroundColor: UIColor.clear, + .underlineColor: UIColor.clear, + ], + range: range + ) + } + + private func hashtagRanges(in text: String, range: NSRange) -> [NSRange] { + matchRanges(using: "(#+[\\w0-9(_)]{0,})", text: text, range: range) + } + + private func mentionRanges(in text: String, range: NSRange) -> [NSRange] { + matchRanges(using: "(@+[a-zA-Z0-9(_).-]{1,})", text: text, range: range) + } + + private func urlRanges(in text: String, range: NSRange) -> [NSRange] { + matchRanges(using: "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)", text: text, range: range) + } + + private func matchRanges(using pattern: String, text: String, range: NSRange) -> [NSRange] { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + return regex.matches(in: text, options: [], range: range).map(\.range) + } + + private func applyHighlightAttributes( + to text: NSMutableAttributedString, + ranges: [NSRange], + tintColor: UIColor + ) { + for range in ranges { + text.addAttributes([.foregroundColor: tintColor], range: range) + } + } + + private func suggestionAction( + for ranges: [NSRange], + text: String, + selectedRange: NSRange + ) -> (action: TextSuggestionAction, range: NSRange?) { + guard !ranges.isEmpty else { + return (.reset, nil) + } + + for range in ranges { + guard selectedRange.location == (range.location + range.length), + let swiftRange = Range(range, in: text) + else { + continue + } + let query = String(text[swiftRange]) + return (.suggest(query: query), range) + } + + return (.reset, nil) + } + + private func applyURLAttributes( + to text: NSMutableAttributedString, + ranges: [NSRange], + tintColor: UIColor + ) -> Int { + var totalUrlLength = 0 + + for range in ranges { + totalUrlLength += range.length + text.addAttributes( + [ + .foregroundColor: tintColor, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: tintColor, + ], + range: range + ) + } + + return totalUrlLength - (maxLengthOfUrl * ranges.count) + } + + private func removeLinkAttributes(from text: NSMutableAttributedString, range: NSRange) { + text.enumerateAttributes(in: range) { attributes, range, _ in + if attributes[.link] != nil { + text.removeAttribute(.link, range: range) + } + } + } + + private func replyMentionText(for status: Status, currentAccount: Account?) -> String { + var mentionString = "" + let author = status.reblog?.account.acct ?? status.account.acct + if author != currentAccount?.acct { + mentionString = "@\(author)" + } + + for mention in status.mentions where mention.acct != currentAccount?.acct { + if !mentionString.isEmpty { + mentionString += " " + } + mentionString += "@\(mention.acct)" + } + + if !mentionString.isEmpty { + mentionString += " " + } + + return mentionString + } + + private func editText(for status: Status) -> String { + var rawText = status.content.asRawText.escape() + for mention in status.mentions { + rawText = rawText.replacingOccurrences(of: "@\(mention.username)", with: "@\(mention.acct)") + } + return rawText + } + + private func legacyQuoteText(for status: Status) -> String { + guard let url = URL(string: status.reblog?.url ?? status.url ?? "") else { return "" } + let author = status.reblog?.account.acct ?? status.account.acct + return "\n\nFrom: @\(author)\n\(url)" + } + + private func trailingSelection(for text: String) -> NSRange { + NSRange(location: text.utf16.count, length: 0) + } + } +} diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/TextState.swift b/Packages/StatusKit/Sources/StatusKit/Editor/TextState.swift new file mode 100644 index 000000000..e3ad093e9 --- /dev/null +++ b/Packages/StatusKit/Sources/StatusKit/Editor/TextState.swift @@ -0,0 +1,11 @@ +import Foundation + +extension StatusEditor { + struct TextState { + var statusText: NSMutableAttributedString = .init(string: "") + var mentionString: String? + var urlLengthAdjustments: Int = 0 + var currentSuggestionRange: NSRange? + var backupStatusText: NSAttributedString? + } +} diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift index 1a0090ef0..af05eee65 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/ViewModel.swift @@ -1,5 +1,4 @@ import AVFoundation -import Combine import DesignSystem import Env import Models @@ -58,30 +57,36 @@ extension StatusEditor { return textView.markedTextRange } - var statusText = NSMutableAttributedString(string: "") { - didSet { - let range = selectedRange - processText() - checkEmbed() - textView?.attributedText = statusText - selectedRange = range - } - } - - private var urlLengthAdjustments: Int = 0 - private let maxLengthOfUrl = 23 + var textState = TextState() + private let textService = TextService() private var spoilerTextCount: Int { spoilerOn ? spoilerText.utf16.count : 0 } var statusTextCharacterLength: Int { - urlLengthAdjustments - statusText.string.utf16.count - spoilerTextCount + textState.urlLengthAdjustments - textState.statusText.string.utf16.count - spoilerTextCount } private var itemsProvider: [NSItemProvider]? - var backupStatusText: NSAttributedString? + var statusText: NSMutableAttributedString { + textState.statusText + } + + var backupStatusText: NSAttributedString? { + get { textState.backupStatusText } + set { textState.backupStatusText = newValue } + } + + var statusTextBinding: Binding { + Binding( + get: { self.textState.statusText }, + set: { newValue in + self.updateStatusText(newValue) + } + ) + } var showPoll: Bool = false var pollVotingFrequency = PollVotingFrequency.oneVote @@ -129,7 +134,7 @@ extension StatusEditor { var showPostingErrorAlert: Bool = false var canPost: Bool { - statusText.length > 0 || !mediaContainers.isEmpty + textState.statusText.length > 0 || !mediaContainers.isEmpty } var shouldDisablePollButton: Bool { @@ -150,9 +155,9 @@ extension StatusEditor { } var shouldDisplayDismissWarning: Bool { - var modifiedStatusText = statusText.string.trimmingCharacters(in: .whitespaces) + var modifiedStatusText = textState.statusText.string.trimmingCharacters(in: .whitespaces) - if let mentionString, modifiedStatusText.hasPrefix(mentionString) { + if let mentionString = textState.mentionString, modifiedStatusText.hasPrefix(mentionString) { modifiedStatusText = String(modifiedStatusText.dropFirst(mentionString.count)) } @@ -169,14 +174,10 @@ extension StatusEditor { var showRecentsTagsInline: Bool = false var selectedLanguage: String? var hasExplicitlySelectedLanguage: Bool = false - private var currentSuggestionRange: NSRange? - private var embeddedStatusURL: URL? { URL(string: embeddedStatus?.reblog?.url ?? embeddedStatus?.url ?? "") } - private var mentionString: String? - private var suggestedTask: Task? init(mode: Mode) { @@ -201,7 +202,7 @@ extension StatusEditor { } func evaluateLanguages() { - if let detectedLang = detectLanguage(text: statusText.string), + if let detectedLang = detectLanguage(text: textState.statusText.string), let selectedLanguage, selectedLanguage != "", selectedLanguage != detectedLang @@ -250,7 +251,7 @@ extension StatusEditor { } } let data = StatusData( - status: statusText.string, + status: textState.statusText.string, visibility: visibility, inReplyToId: mode.replyToStatus?.id, spoilerText: spoilerOn ? spoilerText : nil, @@ -306,42 +307,78 @@ extension StatusEditor { // MARK: - Status Text manipulations func insertStatusText(text: String) { - let string = statusText - string.mutableString.insert(text, at: selectedRange.location) - statusText = string - selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0) - processText() + let update = textService.insertText( + text, + into: textState.statusText, + selection: selectedRange + ) + updateStatusText(update.text, selection: update.selection) } func replaceTextWith(text: String, inRange: NSRange) { - let string = statusText - string.mutableString.deleteCharacters(in: inRange) - string.mutableString.insert(text, at: inRange.location) - statusText = string - selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0) + let update = textService.replaceText( + with: text, + in: textState.statusText, + range: inRange + ) + updateStatusText(update.text, selection: update.selection) if let textView { textView.delegate?.textViewDidChange?(textView) } } func replaceTextWith(text: String) { - statusText = .init(string: text) - selectedRange = .init(location: text.utf16.count, length: 0) + let update = textService.replaceText(with: text) + updateStatusText(update.text, selection: update.selection) + } + + private func updateStatusText(_ text: NSMutableAttributedString, selection: NSRange? = nil) { + let resolvedSelection = selection ?? selectedRange + textState.statusText = text + processText(selection: resolvedSelection) + checkEmbed() + textView?.attributedText = textState.statusText + selectedRange = resolvedSelection + } + + private func applyTextChanges(_ changes: TextService.InitialTextChanges) { + if let visibility = changes.visibility { + self.visibility = visibility + } + if let replyToStatus = changes.replyToStatus { + self.replyToStatus = replyToStatus + } + if let embeddedStatus = changes.embeddedStatus { + self.embeddedStatus = embeddedStatus + } + if let spoilerOn = changes.spoilerOn { + self.spoilerOn = spoilerOn + } + if let spoilerText = changes.spoilerText { + self.spoilerText = spoilerText + } + textState.mentionString = changes.mentionString + + if let statusText = changes.statusText { + updateStatusText(statusText, selection: changes.selectedRange) + } else if let selection = changes.selectedRange { + selectedRange = selection + } } func prepareStatusText() { + let textChanges = textService.initialTextChanges( + for: mode, + currentAccount: currentAccount, + currentInstance: currentInstance + ) + applyTextChanges(textChanges) + switch mode { - case .new(let text, let visibility): - if let text { - statusText = .init(string: text) - selectedRange = .init(location: text.utf16.count, length: 0) - } - self.visibility = visibility case .shareExtension(let items): itemsProvider = items - visibility = .pub processItemsProvider(items: items) - case .imageURL(let urls, let caption, let altTexts, let visibility): + case .imageURL(let urls, _, let altTexts, _): Task { let containers = await Self.makeImageContainer(from: urls) if let altTexts { @@ -356,51 +393,7 @@ extension StatusEditor { prepareToPost(for: container) } } - if let caption, !caption.isEmpty { - statusText = .init(string: caption) - selectedRange = .init(location: caption.utf16.count, length: 0) - } - self.visibility = visibility - case .replyTo(let status): - var mentionString = "" - if (status.reblog?.account.acct ?? status.account.acct) != currentAccount?.acct { - mentionString = "@\(status.reblog?.account.acct ?? status.account.acct)" - } - for mention in status.mentions where mention.acct != currentAccount?.acct { - if !mentionString.isEmpty { - mentionString += " " - } - mentionString += "@\(mention.acct)" - } - if !mentionString.isEmpty { - mentionString += " " - } - replyToStatus = status - visibility = UserPreferences.shared.getReplyVisibility(of: status) - statusText = .init(string: mentionString) - selectedRange = .init(location: mentionString.utf16.count, length: 0) - if !mentionString.isEmpty { - self.mentionString = mentionString.trimmingCharacters(in: .whitespaces) - } - if !status.spoilerText.asRawText.isEmpty { - spoilerOn = true - spoilerText = status.spoilerText.asRawText - } - case .mention(let account, let visibility): - statusText = .init(string: "@\(account.acct) ") - self.visibility = visibility - selectedRange = .init(location: statusText.string.utf16.count, length: 0) case .edit(let status): - var rawText = status.content.asRawText.escape() - for mention in status.mentions { - rawText = rawText.replacingOccurrences( - of: "@\(mention.username)", with: "@\(mention.acct)") - } - statusText = .init(string: rawText) - selectedRange = .init(location: statusText.string.utf16.count, length: 0) - spoilerOn = !status.spoilerText.asRawText.isEmpty - spoilerText = status.spoilerText.asRawText - visibility = status.visibility mediaContainers = status.mediaAttachments.map { MediaContainer.uploaded( id: UUID().uuidString, @@ -408,101 +401,32 @@ extension StatusEditor { originalImage: nil ) } - case .quote(let status): - embeddedStatus = status - if currentInstance?.isQuoteSupported == true { - // Do nothing - } else if let url = embeddedStatusURL { - statusText = .init( - string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)") - selectedRange = .init(location: 0, length: 0) - } - case .quoteLink(let link): - statusText = .init(string: "\n\n\(link)") - selectedRange = .init(location: 0, length: 0) + default: + break } } - private func processText() { - guard markedTextRange == nil else { return } - statusText.addAttributes( - [ - .foregroundColor: UIColor(Theme.shared.labelColor), - .font: Font.scaledBodyUIFont, - .backgroundColor: UIColor.clear, - .underlineColor: UIColor.clear, - ], - range: NSMakeRange(0, statusText.string.utf16.count)) - let hashtagPattern = "(#+[\\w0-9(_)]{0,})" - let mentionPattern = "(@+[a-zA-Z0-9(_).-]{1,})" - let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)" - - do { - let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: []) - let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: []) - let urlRegex = try NSRegularExpression(pattern: urlPattern, options: []) - - let range = NSMakeRange(0, statusText.string.utf16.count) - var ranges = hashtagRegex.matches( - in: statusText.string, - options: [], - range: range - ).map(\.range) - ranges.append( - contentsOf: mentionRegex.matches( - in: statusText.string, - options: [], - range: range - ).map(\.range)) - - let urlRanges = urlRegex.matches( - in: statusText.string, - options: [], - range: range - ).map(\.range) - - var foundSuggestionRange = false - for nsRange in ranges { - statusText.addAttributes( - [.foregroundColor: UIColor(theme?.tintColor ?? .brand)], - range: nsRange) - if selectedRange.location == (nsRange.location + nsRange.length), - let range = Range(nsRange, in: statusText.string) - { - foundSuggestionRange = true - currentSuggestionRange = nsRange - loadAutoCompleteResults(query: String(statusText.string[range])) - } - } - - if !foundSuggestionRange || ranges.isEmpty { - resetAutoCompletion() - } - - var totalUrlLength = 0 - var numUrls = 0 - - for range in urlRanges { - numUrls += 1 - totalUrlLength += range.length - - statusText.addAttributes( - [ - .foregroundColor: UIColor(theme?.tintColor ?? .brand), - .underlineStyle: NSUnderlineStyle.single.rawValue, - .underlineColor: UIColor(theme?.tintColor ?? .brand), - ], - range: NSRange(location: range.location, length: range.length)) - } + private func processText(selection: NSRange) { + let result = textService.processText( + textState.statusText, + theme: theme, + selectedRange: selection, + hasMarkedText: markedTextRange != nil, + previousUrlLengthAdjustments: textState.urlLengthAdjustments + ) + guard result.didProcess else { return } - urlLengthAdjustments = totalUrlLength - (maxLengthOfUrl * numUrls) + textState.urlLengthAdjustments = result.urlLengthAdjustments + textState.currentSuggestionRange = result.suggestionRange - statusText.enumerateAttributes(in: range) { attributes, range, _ in - if attributes[.link] != nil { - statusText.removeAttribute(.link, range: range) - } - } - } catch {} + switch result.action { + case .suggest(let query): + loadAutoCompleteResults(query: query) + case .reset: + resetAutoCompletion() + case .none: + break + } } // MARK: - Shar sheet / Item provider @@ -579,9 +503,8 @@ extension StatusEditor { } } } - if !initialText.isEmpty { - statusText = .init(string: "\n\n\(initialText)") - selectedRange = .init(location: 0, length: 0) + if !initialText.isEmpty { + updateStatusText(.init(string: "\n\n\(initialText)"), selection: .init(location: 0, length: 0)) } } } @@ -604,7 +527,7 @@ extension StatusEditor { private func checkEmbed() { if let url = embeddedStatusURL, currentInstance?.isQuoteSupported == false, - !statusText.string.contains(url.absoluteString) + !textState.statusText.string.contains(url.absoluteString) { embeddedStatus = nil mode = .new(text: nil, visibility: visibility) @@ -667,26 +590,27 @@ extension StatusEditor { } private func resetAutoCompletion() { - if !tagsSuggestions.isEmpty || !mentionsSuggestions.isEmpty || currentSuggestionRange != nil + if !tagsSuggestions.isEmpty || !mentionsSuggestions.isEmpty + || textState.currentSuggestionRange != nil || showRecentsTagsInline { withAnimation { tagsSuggestions = [] mentionsSuggestions = [] - currentSuggestionRange = nil + textState.currentSuggestionRange = nil showRecentsTagsInline = false } } } func selectMentionSuggestion(account: Account) { - if let range = currentSuggestionRange { + if let range = textState.currentSuggestionRange { replaceTextWith(text: "@\(account.acct) ", inRange: range) } } func selectHashtagSuggestion(tag: String) { - if let range = currentSuggestionRange { + if let range = textState.currentSuggestionRange { var tag = tag if tag.hasPrefix("#") { tag.removeFirst() @@ -704,23 +628,23 @@ extension StatusEditor { var newStream: LanguageModelSession.ResponseStream? switch prompt { case .correct: - newStream = await assistant.correct(message: statusText.string) + newStream = await assistant.correct(message: textState.statusText.string) case .emphasize: - newStream = await assistant.emphasize(message: statusText.string) + newStream = await assistant.emphasize(message: textState.statusText.string) case .fit: - newStream = await assistant.shorten(message: statusText.string) + newStream = await assistant.shorten(message: textState.statusText.string) case .rewriteWithTone(let tone): - newStream = await assistant.adjustTone(message: statusText.string, to: tone) + newStream = await assistant.adjustTone(message: textState.statusText.string, to: tone) } if let newStream { - backupStatusText = statusText + textState.backupStatusText = textState.statusText do { for try await content in newStream { replaceTextWith(text: content.content) } } catch { - if let backupStatusText { + if let backupStatusText = textState.backupStatusText { replaceTextWith(text: backupStatusText.string) } } diff --git a/Packages/StatusKit/Tests/StatusKitTests/TextServiceTests.swift b/Packages/StatusKit/Tests/StatusKitTests/TextServiceTests.swift new file mode 100644 index 000000000..79395ddae --- /dev/null +++ b/Packages/StatusKit/Tests/StatusKitTests/TextServiceTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Models +@testable import StatusKit +import UniformTypeIdentifiers +import XCTest + +@MainActor +final class TextServiceTests: XCTestCase { + func testProcessTextSuggestsHashtagAtCursor() { + let service = StatusEditor.TextService() + let text = NSMutableAttributedString(string: "Testing #icecubes") + let selection = NSRange(location: text.string.utf16.count, length: 0) + + let result = service.processText( + text, + theme: nil, + selectedRange: selection, + hasMarkedText: false, + previousUrlLengthAdjustments: 0 + ) + + XCTAssertTrue(result.didProcess) + XCTAssertEqual(result.action, .suggest(query: "#icecubes")) + } + + func testProcessTextResetsSuggestionWhenCursorIsOutsideRange() { + let service = StatusEditor.TextService() + let text = NSMutableAttributedString(string: "Testing #icecubes ") + let selection = NSRange(location: text.string.utf16.count, length: 0) + + let result = service.processText( + text, + theme: nil, + selectedRange: selection, + hasMarkedText: false, + previousUrlLengthAdjustments: 0 + ) + + XCTAssertTrue(result.didProcess) + XCTAssertEqual(result.action, .reset) + } + + func testProcessTextCountsURLLengthAdjustments() { + let service = StatusEditor.TextService() + let text = NSMutableAttributedString( + string: "Check https://example.com and https://example.org" + ) + let selection = NSRange(location: text.string.utf16.count, length: 0) + + let result = service.processText( + text, + theme: nil, + selectedRange: selection, + hasMarkedText: false, + previousUrlLengthAdjustments: 0 + ) + + XCTAssertTrue(result.didProcess) + XCTAssertEqual(result.urlLengthAdjustments, -8) + } + + func testProcessTextResetsSuggestionWhenCursorIsInsideHashtag() { + let service = StatusEditor.TextService() + let text = NSMutableAttributedString(string: "Testing #icecubes") + let selection = NSRange(location: "Testing #ice".utf16.count, length: 0) + + let result = service.processText( + text, + theme: nil, + selectedRange: selection, + hasMarkedText: false, + previousUrlLengthAdjustments: 0 + ) + + XCTAssertTrue(result.didProcess) + XCTAssertEqual(result.action, .reset) + } + + func testProcessTextSuggestsHashtagWithEmojiPrefix() { + let service = StatusEditor.TextService() + let text = NSMutableAttributedString(string: "Hello 😄 #icecubes") + let selection = NSRange(location: text.string.utf16.count, length: 0) + + let result = service.processText( + text, + theme: nil, + selectedRange: selection, + hasMarkedText: false, + previousUrlLengthAdjustments: 0 + ) + + XCTAssertTrue(result.didProcess) + XCTAssertEqual(result.action, .suggest(query: "#icecubes")) + } + + func testInitialTextChangesForReplyMentionsAuthorAndMentions() { + let service = StatusEditor.TextService() + let author = makeAccount(acct: "alice", username: "alice") + let mention = Mention( + id: "mention-1", + username: "bob", + url: URL(string: "https://example.com/@bob")!, + acct: "bob" + ) + let status = makeStatus(account: author, mentions: [mention]) + + let changes = service.initialTextChanges( + for: .replyTo(status: status), + currentAccount: makeAccount(acct: "current", username: "current"), + currentInstance: nil + ) + + XCTAssertEqual(changes.statusText?.string, "@alice @bob ") + XCTAssertEqual(changes.mentionString, "@alice @bob") + XCTAssertEqual(changes.selectedRange?.location, "@alice @bob ".utf16.count) + } + + func testInitialTextChangesForEditNormalizesMentionAcct() { + let service = StatusEditor.TextService() + let author = makeAccount(acct: "alice", username: "alice") + let mention = Mention( + id: "mention-1", + username: "bob", + url: URL(string: "https://example.com/@bob")!, + acct: "bob@server" + ) + let status = makeStatus( + account: author, + mentions: [mention], + content: HTMLString(stringValue: "Hello @bob") + ) + + let changes = service.initialTextChanges( + for: .edit(status: status), + currentAccount: nil, + currentInstance: nil + ) + + XCTAssertEqual(changes.statusText?.string, "Hello @bob@server") + } + + func testInitialTextChangesForLegacyQuoteIncludesAuthorAndURL() { + let service = StatusEditor.TextService() + let author = makeAccount(acct: "alice", username: "alice") + let status = makeStatus( + account: author, + mentions: [], + url: URL(string: "https://example.com/@alice/1") + ) + + let changes = service.initialTextChanges( + for: .quote(status: status), + currentAccount: nil, + currentInstance: nil + ) + + XCTAssertEqual( + changes.statusText?.string, + "\n\nFrom: @alice\nhttps://example.com/@alice/1" + ) + } + + func testShareExtensionTextItemsInsertLeadingText() async { + let item = NSItemProvider( + item: "Hello" as NSString, + typeIdentifier: UTType.plainText.identifier + ) + let viewModel = await MainActor.run { + StatusEditor.ViewModel(mode: .shareExtension(items: [item])) + } + + await MainActor.run { + viewModel.prepareStatusText() + } + + let expectation = XCTestExpectation(description: "Wait for share text") + Task { + for _ in 0..<20 { + let text = await MainActor.run { viewModel.statusText.string } + if text == "\n\nHello " { + expectation.fulfill() + return + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + await fulfillment(of: [expectation], timeout: 3.0) + } +} + +private func makeAccount(acct: String, username: String) -> Account { + Account( + id: UUID().uuidString, + username: username, + displayName: nil, + avatar: URL(string: "https://example.com/avatar.png")!, + header: URL(string: "https://example.com/header.png")!, + acct: acct, + note: HTMLString(stringValue: ""), + createdAt: ServerDate(), + followersCount: 0, + followingCount: 0, + statusesCount: 0, + fields: [], + locked: false, + emojis: [], + url: URL(string: "https://example.com/@\(acct)"), + source: nil, + bot: false, + discoverable: nil, + moved: nil + ) +} + +private func makeStatus( + account: Account, + mentions: [Mention], + content: HTMLString = HTMLString(stringValue: "Hello"), + url: URL? = nil +) -> Status { + Status( + id: UUID().uuidString, + content: content, + account: account, + createdAt: ServerDate(), + editedAt: nil, + reblog: nil, + mediaAttachments: [], + mentions: mentions, + repliesCount: 0, + reblogsCount: 0, + favouritesCount: 0, + card: nil, + favourited: nil, + reblogged: nil, + pinned: nil, + bookmarked: nil, + emojis: [], + url: url?.absoluteString, + application: nil, + inReplyToId: nil, + inReplyToAccountId: nil, + visibility: .pub, + poll: nil, + spoilerText: HTMLString(stringValue: ""), + filtered: nil, + sensitive: false, + language: nil, + tags: [], + quote: nil, + quotesCount: nil, + quoteApproval: nil + ) +}