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
+ )
+}