2024-01-06 17:43:26 +00:00
|
|
|
import Combine
|
|
|
|
import DesignSystem
|
|
|
|
import Env
|
|
|
|
import Models
|
|
|
|
import NaturalLanguage
|
|
|
|
import Network
|
|
|
|
import PhotosUI
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
extension StatusEditor {
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
@Observable public class ViewModel: NSObject, Identifiable {
|
|
|
|
public let id = UUID()
|
|
|
|
|
|
|
|
var mode: Mode
|
|
|
|
|
|
|
|
var client: Client?
|
|
|
|
var currentAccount: Account? {
|
|
|
|
didSet {
|
|
|
|
if let itemsProvider {
|
|
|
|
mediaContainers = []
|
|
|
|
processItemsProvider(items: itemsProvider)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var theme: Theme?
|
|
|
|
var preferences: UserPreferences?
|
|
|
|
var languageConfirmationDialogLanguages: (detected: String, selected: String)?
|
|
|
|
|
|
|
|
var textView: UITextView? {
|
|
|
|
didSet {
|
|
|
|
textView?.pasteDelegate = self
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var selectedRange: NSRange {
|
|
|
|
get {
|
|
|
|
guard let textView else {
|
|
|
|
return .init(location: 0, length: 0)
|
|
|
|
}
|
|
|
|
return textView.selectedRange
|
|
|
|
}
|
|
|
|
set {
|
|
|
|
textView?.selectedRange = newValue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var markedTextRange: UITextRange? {
|
|
|
|
guard let textView else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
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
|
|
|
|
|
|
|
|
private var spoilerTextCount: Int {
|
|
|
|
spoilerOn ? spoilerText.utf16.count : 0
|
|
|
|
}
|
|
|
|
|
|
|
|
var statusTextCharacterLength: Int {
|
|
|
|
urlLengthAdjustments - statusText.string.utf16.count - spoilerTextCount
|
|
|
|
}
|
|
|
|
|
|
|
|
private var itemsProvider: [NSItemProvider]?
|
|
|
|
|
|
|
|
var backupStatusText: NSAttributedString?
|
|
|
|
|
|
|
|
var showPoll: Bool = false
|
|
|
|
var pollVotingFrequency = PollVotingFrequency.oneVote
|
|
|
|
var pollDuration = Duration.oneDay
|
|
|
|
var pollOptions: [String] = ["", ""]
|
|
|
|
|
|
|
|
var spoilerOn: Bool = false
|
|
|
|
var spoilerText: String = ""
|
|
|
|
|
2024-01-09 15:17:34 +00:00
|
|
|
var postingProgress: Double = 0.0
|
|
|
|
var postingTimer: Timer?
|
2024-01-06 17:43:26 +00:00
|
|
|
var isPosting: Bool = false
|
2024-01-09 15:17:34 +00:00
|
|
|
|
2024-01-06 17:43:26 +00:00
|
|
|
var mediaPickers: [PhotosPickerItem] = [] {
|
|
|
|
didSet {
|
|
|
|
if mediaPickers.count > 4 {
|
|
|
|
mediaPickers = mediaPickers.prefix(4).map { $0 }
|
|
|
|
}
|
|
|
|
|
|
|
|
let removedIDs = oldValue
|
|
|
|
.filter { !mediaPickers.contains($0) }
|
|
|
|
.compactMap(\.itemIdentifier)
|
|
|
|
mediaContainers.removeAll { removedIDs.contains($0.id) }
|
|
|
|
|
|
|
|
let newPickerItems = mediaPickers.filter { !oldValue.contains($0) }
|
|
|
|
if !newPickerItems.isEmpty {
|
|
|
|
isMediasLoading = true
|
|
|
|
for item in newPickerItems {
|
|
|
|
prepareToPost(for: item)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var isMediasLoading: Bool = false
|
|
|
|
|
|
|
|
var mediaContainers: [MediaContainer] = []
|
|
|
|
var replyToStatus: Status?
|
|
|
|
var embeddedStatus: Status?
|
|
|
|
|
|
|
|
var customEmojiContainer: [CategorizedEmojiContainer] = []
|
|
|
|
|
|
|
|
var postingError: String?
|
|
|
|
var showPostingErrorAlert: Bool = false
|
|
|
|
|
|
|
|
var canPost: Bool {
|
|
|
|
statusText.length > 0 || !mediaContainers.isEmpty
|
|
|
|
}
|
|
|
|
|
|
|
|
var shouldDisablePollButton: Bool {
|
2024-01-07 06:03:39 +00:00
|
|
|
!mediaContainers.isEmpty
|
2024-01-06 17:43:26 +00:00
|
|
|
}
|
2024-01-22 05:28:03 +00:00
|
|
|
|
|
|
|
var allMediaHasDescription: Bool {
|
|
|
|
var everyMediaHasAltText: Bool = true;
|
|
|
|
mediaContainers.forEach { mediaContainer in
|
|
|
|
if (((mediaContainer.mediaAttachment?.description) == nil) ||
|
|
|
|
mediaContainer.mediaAttachment?.description?.count == 0) {
|
|
|
|
everyMediaHasAltText = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return everyMediaHasAltText;
|
|
|
|
}
|
2024-01-06 17:43:26 +00:00
|
|
|
|
|
|
|
var shouldDisplayDismissWarning: Bool {
|
|
|
|
var modifiedStatusText = statusText.string.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
|
|
|
if let mentionString, modifiedStatusText.hasPrefix(mentionString) {
|
|
|
|
modifiedStatusText = String(modifiedStatusText.dropFirst(mentionString.count))
|
|
|
|
}
|
|
|
|
|
|
|
|
return !modifiedStatusText.isEmpty && !mode.isInShareExtension
|
|
|
|
}
|
|
|
|
|
|
|
|
var visibility: Models.Visibility = .pub
|
|
|
|
|
|
|
|
var mentionsSuggestions: [Account] = []
|
|
|
|
var tagsSuggestions: [Tag] = []
|
|
|
|
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<Void, Never>?
|
|
|
|
|
|
|
|
init(mode: Mode) {
|
|
|
|
self.mode = mode
|
|
|
|
}
|
|
|
|
|
|
|
|
func setInitialLanguageSelection(preference: String?) {
|
|
|
|
switch mode {
|
|
|
|
case let .edit(status), let .quote(status):
|
|
|
|
selectedLanguage = status.language
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
selectedLanguage = selectedLanguage ?? preference ?? currentAccount?.source?.language
|
|
|
|
}
|
|
|
|
|
|
|
|
func evaluateLanguages() {
|
|
|
|
if let detectedLang = detectLanguage(text: statusText.string),
|
|
|
|
let selectedLanguage,
|
|
|
|
selectedLanguage != "",
|
|
|
|
selectedLanguage != detectedLang
|
|
|
|
{
|
|
|
|
languageConfirmationDialogLanguages = (detected: detectedLang, selected: selectedLanguage)
|
|
|
|
} else {
|
|
|
|
languageConfirmationDialogLanguages = nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func postStatus() async -> Status? {
|
|
|
|
guard let client else { return nil }
|
|
|
|
do {
|
2024-01-22 05:28:03 +00:00
|
|
|
if (!allMediaHasDescription && UserPreferences.shared.appRequireAltText) {
|
|
|
|
throw PostError.missingAltText
|
|
|
|
}
|
|
|
|
|
2024-01-09 15:17:34 +00:00
|
|
|
if postingTimer == nil {
|
|
|
|
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
|
|
|
|
Task { @MainActor in
|
|
|
|
if self.postingProgress < 100 {
|
|
|
|
self.postingProgress += 0.5
|
|
|
|
}
|
|
|
|
if self.postingProgress >= 100 {
|
|
|
|
self.postingTimer?.invalidate()
|
|
|
|
self.postingTimer = nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-01-06 17:43:26 +00:00
|
|
|
isPosting = true
|
|
|
|
let postStatus: Status?
|
|
|
|
var pollData: StatusData.PollData?
|
|
|
|
if let pollOptions = getPollOptionsForAPI() {
|
|
|
|
pollData = .init(options: pollOptions,
|
|
|
|
multiple: pollVotingFrequency.canVoteMultipleTimes,
|
|
|
|
expires_in: pollDuration.rawValue)
|
|
|
|
}
|
|
|
|
let data = StatusData(status: statusText.string,
|
|
|
|
visibility: visibility,
|
|
|
|
inReplyToId: mode.replyToStatus?.id,
|
|
|
|
spoilerText: spoilerOn ? spoilerText : nil,
|
|
|
|
mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id },
|
|
|
|
poll: pollData,
|
|
|
|
language: selectedLanguage,
|
|
|
|
mediaAttributes: mediaAttributes)
|
|
|
|
switch mode {
|
|
|
|
case .new, .replyTo, .quote, .mention, .shareExtension:
|
|
|
|
postStatus = try await client.post(endpoint: Statuses.postStatus(json: data))
|
2024-01-10 06:28:08 +00:00
|
|
|
if let postStatus {
|
|
|
|
StreamWatcher.shared.emmitPostEvent(for: postStatus)
|
|
|
|
}
|
2024-01-06 17:43:26 +00:00
|
|
|
case let .edit(status):
|
|
|
|
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
|
2024-01-10 06:28:08 +00:00
|
|
|
if let postStatus {
|
|
|
|
StreamWatcher.shared.emmitEditEvent(for: postStatus)
|
|
|
|
}
|
2024-01-09 15:17:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
postingTimer?.invalidate()
|
|
|
|
postingTimer = nil
|
|
|
|
|
2024-01-10 06:28:08 +00:00
|
|
|
withAnimation {
|
|
|
|
postingProgress = 99.0
|
2024-01-06 17:43:26 +00:00
|
|
|
}
|
2024-01-10 06:28:08 +00:00
|
|
|
try await Task.sleep(for: .seconds(0.5))
|
|
|
|
HapticManager.shared.fireHaptic(.notification(.success))
|
2024-01-09 15:17:34 +00:00
|
|
|
|
2024-01-06 17:43:26 +00:00
|
|
|
if hasExplicitlySelectedLanguage, let selectedLanguage {
|
|
|
|
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
|
|
|
|
}
|
|
|
|
isPosting = false
|
2024-01-09 15:17:34 +00:00
|
|
|
|
2024-01-06 17:43:26 +00:00
|
|
|
return postStatus
|
|
|
|
} catch {
|
|
|
|
if let error = error as? Models.ServerError {
|
|
|
|
postingError = error.error
|
|
|
|
showPostingErrorAlert = true
|
|
|
|
}
|
2024-01-22 05:28:03 +00:00
|
|
|
if let postError = error as? PostError {
|
|
|
|
postingError = postError.description
|
|
|
|
showPostingErrorAlert = true
|
|
|
|
}
|
2024-01-06 17:43:26 +00:00
|
|
|
isPosting = false
|
|
|
|
HapticManager.shared.fireHaptic(.notification(.error))
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
func replaceTextWith(text: String) {
|
|
|
|
statusText = .init(string: text)
|
|
|
|
selectedRange = .init(location: text.utf16.count, length: 0)
|
|
|
|
}
|
|
|
|
|
|
|
|
func prepareStatusText() {
|
|
|
|
switch mode {
|
|
|
|
case let .new(visibility):
|
|
|
|
self.visibility = visibility
|
|
|
|
case let .shareExtension(items):
|
|
|
|
itemsProvider = items
|
|
|
|
visibility = .pub
|
|
|
|
processItemsProvider(items: items)
|
|
|
|
case let .replyTo(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 let .mention(account, visibility):
|
|
|
|
statusText = .init(string: "@\(account.acct) ")
|
|
|
|
self.visibility = visibility
|
|
|
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
|
|
|
case let .edit(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(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: $0,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
case let .quote(status):
|
|
|
|
embeddedStatus = status
|
|
|
|
if let url = embeddedStatusURL {
|
|
|
|
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
|
|
|
selectedRange = .init(location: 0, length: 0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
|
|
|
urlLengthAdjustments = totalUrlLength - (maxLengthOfUrl * numUrls)
|
|
|
|
|
|
|
|
statusText.enumerateAttributes(in: range) { attributes, range, _ in
|
|
|
|
if attributes[.link] != nil {
|
|
|
|
statusText.removeAttribute(.link, range: range)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Shar sheet / Item provider
|
|
|
|
|
|
|
|
func processURLs(urls: [URL]) {
|
|
|
|
isMediasLoading = true
|
|
|
|
let items = urls.filter { $0.startAccessingSecurityScopedResource() }
|
|
|
|
.compactMap { NSItemProvider(contentsOf: $0) }
|
|
|
|
processItemsProvider(items: items)
|
|
|
|
}
|
|
|
|
|
|
|
|
func processGIFData(data: Data) {
|
|
|
|
isMediasLoading = true
|
|
|
|
let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).gif")
|
|
|
|
try? data.write(to: url)
|
|
|
|
let container = MediaContainer(id: UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: .init(url: url),
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
}
|
|
|
|
|
|
|
|
func processCameraPhoto(image: UIImage) {
|
|
|
|
let container = MediaContainer(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: image,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func processItemsProvider(items: [NSItemProvider]) {
|
|
|
|
Task {
|
|
|
|
var initialText: String = ""
|
|
|
|
for item in items {
|
2024-01-20 18:17:59 +00:00
|
|
|
if let identifier = item.registeredTypeIdentifiers.first {
|
|
|
|
let handledItemType = UTTypeSupported(value: identifier)
|
2024-01-06 17:43:26 +00:00
|
|
|
do {
|
|
|
|
let compressor = Compressor()
|
|
|
|
let content = try await handledItemType.loadItemContent(item: item)
|
|
|
|
if let text = content as? String {
|
|
|
|
initialText += "\(text) "
|
|
|
|
} else if let image = content as? UIImage {
|
|
|
|
let container = MediaContainer(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: image,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
} else if let content = content as? ImageFileTranseferable,
|
|
|
|
let compressedData = await compressor.compressImageFrom(url: content.url),
|
|
|
|
let image = UIImage(data: compressedData)
|
|
|
|
{
|
|
|
|
let container = MediaContainer(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: image,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
} else if let video = content as? MovieFileTranseferable {
|
|
|
|
let container = MediaContainer(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: video,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
} else if let gif = content as? GifFileTranseferable {
|
|
|
|
let container = MediaContainer(
|
|
|
|
id: UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: gif,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
prepareToPost(for: container)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
isMediasLoading = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !initialText.isEmpty {
|
|
|
|
statusText = .init(string: initialText)
|
|
|
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Polls
|
|
|
|
|
|
|
|
func resetPollDefaults() {
|
|
|
|
pollOptions = ["", ""]
|
|
|
|
pollDuration = .oneDay
|
|
|
|
pollVotingFrequency = .oneVote
|
|
|
|
}
|
|
|
|
|
|
|
|
private func getPollOptionsForAPI() -> [String]? {
|
|
|
|
let options = pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
|
|
return options.isEmpty ? nil : options
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Embeds
|
|
|
|
|
|
|
|
private func checkEmbed() {
|
|
|
|
if let url = embeddedStatusURL,
|
|
|
|
!statusText.string.contains(url.absoluteString)
|
|
|
|
{
|
|
|
|
embeddedStatus = nil
|
|
|
|
mode = .new(visibility: visibility)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Autocomplete
|
|
|
|
|
|
|
|
private func loadAutoCompleteResults(query: String) {
|
|
|
|
guard let client else { return }
|
|
|
|
var query = query
|
|
|
|
suggestedTask?.cancel()
|
|
|
|
suggestedTask = Task {
|
|
|
|
do {
|
|
|
|
var results: SearchResults?
|
|
|
|
switch query.first {
|
|
|
|
case "#":
|
|
|
|
if query.utf8.count == 1 {
|
|
|
|
withAnimation {
|
|
|
|
showRecentsTagsInline = true
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
showRecentsTagsInline = false
|
|
|
|
query.removeFirst()
|
|
|
|
results = try await client.get(endpoint: Search.search(query: query,
|
|
|
|
type: "hashtags",
|
|
|
|
offset: 0,
|
|
|
|
following: nil),
|
|
|
|
forceVersion: .v2)
|
|
|
|
guard !Task.isCancelled else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
withAnimation {
|
|
|
|
tagsSuggestions = results?.hashtags.sorted(by: { $0.totalUses > $1.totalUses }) ?? []
|
|
|
|
}
|
|
|
|
case "@":
|
|
|
|
guard query.utf8.count > 1 else { return }
|
|
|
|
query.removeFirst()
|
|
|
|
let accounts: [Account] = try await client.get(endpoint: Search.accountsSearch(query: query,
|
|
|
|
type: nil,
|
|
|
|
offset: 0,
|
|
|
|
following: nil),
|
|
|
|
forceVersion: .v1)
|
|
|
|
guard !Task.isCancelled else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
withAnimation {
|
|
|
|
mentionsSuggestions = accounts
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
break
|
|
|
|
}
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func resetAutoCompletion() {
|
2024-01-15 14:03:28 +00:00
|
|
|
if !tagsSuggestions.isEmpty ||
|
|
|
|
!mentionsSuggestions.isEmpty ||
|
|
|
|
currentSuggestionRange != nil ||
|
|
|
|
showRecentsTagsInline {
|
|
|
|
withAnimation {
|
|
|
|
tagsSuggestions = []
|
|
|
|
mentionsSuggestions = []
|
|
|
|
currentSuggestionRange = nil
|
|
|
|
showRecentsTagsInline = false
|
|
|
|
}
|
2024-01-06 17:43:26 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func selectMentionSuggestion(account: Account) {
|
|
|
|
if let range = currentSuggestionRange {
|
|
|
|
replaceTextWith(text: "@\(account.acct) ", inRange: range)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func selectHashtagSuggestion(tag: String) {
|
|
|
|
if let range = currentSuggestionRange {
|
|
|
|
replaceTextWith(text: "#\(tag) ", inRange: range)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - OpenAI Prompt
|
|
|
|
|
|
|
|
func runOpenAI(prompt: OpenAIClient.Prompt) async {
|
|
|
|
do {
|
|
|
|
let client = OpenAIClient()
|
|
|
|
let response = try await client.request(prompt)
|
|
|
|
backupStatusText = statusText
|
|
|
|
replaceTextWith(text: response.trimmedText)
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Media related function
|
|
|
|
|
|
|
|
private func indexOf(container: MediaContainer) -> Int? {
|
|
|
|
mediaContainers.firstIndex(where: { $0.id == container.id })
|
|
|
|
}
|
|
|
|
|
|
|
|
func prepareToPost(for pickerItem: PhotosPickerItem) {
|
|
|
|
Task(priority: .high) {
|
|
|
|
if let container = await makeMediaContainer(from: pickerItem) {
|
|
|
|
self.mediaContainers.append(container)
|
|
|
|
await upload(container: container)
|
|
|
|
self.isMediasLoading = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func prepareToPost(for container: MediaContainer) {
|
|
|
|
Task(priority: .high) {
|
|
|
|
self.mediaContainers.append(container)
|
|
|
|
await upload(container: container)
|
|
|
|
self.isMediasLoading = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-26 12:01:23 +00:00
|
|
|
nonisolated func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
2024-01-06 17:43:26 +00:00
|
|
|
await withTaskGroup(of: MediaContainer?.self, returning: MediaContainer?.self) { taskGroup in
|
|
|
|
taskGroup.addTask(priority: .high) { await Self.makeImageContainer(from: pickerItem) }
|
|
|
|
taskGroup.addTask(priority: .high) { await Self.makeGifContainer(from: pickerItem) }
|
|
|
|
taskGroup.addTask(priority: .high) { await Self.makeMovieContainer(from: pickerItem) }
|
|
|
|
|
|
|
|
for await container in taskGroup {
|
|
|
|
if let container {
|
|
|
|
taskGroup.cancelAll()
|
|
|
|
return container
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func makeGifContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
|
|
|
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
|
|
|
|
|
|
|
|
return MediaContainer(
|
|
|
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: gifFile,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
|
|
|
guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTranseferable.self) else { return nil }
|
|
|
|
|
|
|
|
return MediaContainer(
|
|
|
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: movieFile,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
|
|
|
guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
|
|
|
|
|
|
|
|
let compressor = Compressor()
|
|
|
|
|
|
|
|
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
|
|
|
|
let image = UIImage(data: compressedData)
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
return MediaContainer(
|
|
|
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
|
|
image: image,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func upload(container: MediaContainer) async {
|
|
|
|
if let index = indexOf(container: container) {
|
|
|
|
let originalContainer = mediaContainers[index]
|
|
|
|
guard originalContainer.mediaAttachment == nil else { return }
|
|
|
|
let newContainer = MediaContainer(
|
|
|
|
id: originalContainer.id,
|
|
|
|
image: originalContainer.image,
|
|
|
|
movieTransferable: originalContainer.movieTransferable,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
mediaContainers[index] = newContainer
|
|
|
|
do {
|
|
|
|
let compressor = Compressor()
|
|
|
|
if let image = originalContainer.image {
|
|
|
|
let imageData = try await compressor.compressImageForUpload(image)
|
|
|
|
let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg")
|
|
|
|
if let index = indexOf(container: newContainer) {
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: originalContainer.id,
|
|
|
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: uploadedMedia,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if let uploadedMedia, uploadedMedia.url == nil {
|
|
|
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
|
|
}
|
|
|
|
} else if let videoURL = originalContainer.movieTransferable?.url,
|
|
|
|
let compressedVideoURL = await compressor.compressVideo(videoURL),
|
|
|
|
let data = try? Data(contentsOf: compressedVideoURL)
|
|
|
|
{
|
|
|
|
let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType())
|
|
|
|
if let index = indexOf(container: newContainer) {
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: originalContainer.id,
|
|
|
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
|
|
movieTransferable: originalContainer.movieTransferable,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: uploadedMedia,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if let uploadedMedia, uploadedMedia.url == nil {
|
|
|
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
|
|
}
|
|
|
|
} else if let gifData = originalContainer.gifTransferable?.data {
|
|
|
|
let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif")
|
|
|
|
if let index = indexOf(container: newContainer) {
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: originalContainer.id,
|
|
|
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: originalContainer.gifTransferable,
|
|
|
|
mediaAttachment: uploadedMedia,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
if let uploadedMedia, uploadedMedia.url == nil {
|
|
|
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
if let index = indexOf(container: newContainer) {
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: originalContainer.id,
|
|
|
|
image: originalContainer.image,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: nil,
|
|
|
|
error: error
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
|
|
|
|
Task {
|
|
|
|
repeat {
|
|
|
|
if let client,
|
|
|
|
let index = mediaContainers.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
|
|
|
|
{
|
|
|
|
guard mediaContainers[index].mediaAttachment?.url == nil else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
do {
|
|
|
|
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
|
|
|
|
json: nil))
|
|
|
|
if newAttachement.url != nil {
|
|
|
|
let oldContainer = mediaContainers[index]
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: mediaAttachement.id,
|
|
|
|
image: oldContainer.image,
|
|
|
|
movieTransferable: oldContainer.movieTransferable,
|
|
|
|
gifTransferable: oldContainer.gifTransferable,
|
|
|
|
mediaAttachment: newAttachement,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
print(error.localizedDescription)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
try? await Task.sleep(for: .seconds(5))
|
|
|
|
} while !Task.isCancelled
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func addDescription(container: MediaContainer, description: String) async {
|
|
|
|
guard let client, let attachment = container.mediaAttachment else { return }
|
|
|
|
if let index = indexOf(container: container) {
|
|
|
|
do {
|
|
|
|
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
|
|
|
|
json: .init(description: description)))
|
|
|
|
mediaContainers[index] = MediaContainer(
|
|
|
|
id: container.id,
|
|
|
|
image: nil,
|
|
|
|
movieTransferable: nil,
|
|
|
|
gifTransferable: nil,
|
|
|
|
mediaAttachment: media,
|
|
|
|
error: nil
|
|
|
|
)
|
|
|
|
} catch { print(error) }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private var mediaAttributes: [StatusData.MediaAttribute] = []
|
|
|
|
func editDescription(container: MediaContainer, description: String) async {
|
|
|
|
guard let attachment = container.mediaAttachment else { return }
|
|
|
|
if indexOf(container: container) != nil {
|
|
|
|
mediaAttributes.append(StatusData.MediaAttribute(id: attachment.id, description: description, thumbnail: nil, focus: nil))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
|
|
|
|
guard let client else { return nil }
|
|
|
|
return try await client.mediaUpload(endpoint: Media.medias,
|
|
|
|
version: .v2,
|
|
|
|
method: "POST",
|
|
|
|
mimeType: mimeType,
|
|
|
|
filename: "file",
|
|
|
|
data: data)
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Custom emojis
|
|
|
|
|
|
|
|
func fetchCustomEmojis() async {
|
|
|
|
typealias EmojiContainer = CategorizedEmojiContainer
|
|
|
|
|
|
|
|
guard let client else { return }
|
|
|
|
do {
|
|
|
|
let customEmojis: [Emoji] = try await client.get(endpoint: CustomEmojis.customEmojis) ?? []
|
|
|
|
var emojiContainers: [EmojiContainer] = []
|
|
|
|
|
|
|
|
customEmojis.reduce([String: [Emoji]]()) { currentDict, emoji in
|
|
|
|
var dict = currentDict
|
2024-01-21 09:49:40 +00:00
|
|
|
let category = emoji.category ?? "Custom"
|
2024-01-06 17:43:26 +00:00
|
|
|
|
|
|
|
if let emojis = dict[category] {
|
|
|
|
dict[category] = emojis + [emoji]
|
|
|
|
} else {
|
|
|
|
dict[category] = [emoji]
|
|
|
|
}
|
|
|
|
|
|
|
|
return dict
|
|
|
|
}.sorted(by: { lhs, rhs in
|
2024-01-21 09:49:40 +00:00
|
|
|
if rhs.key == "Custom" { false }
|
|
|
|
else if lhs.key == "Custom" { true }
|
2024-01-06 17:43:26 +00:00
|
|
|
else { lhs.key < rhs.key }
|
|
|
|
}).forEach { key, value in
|
|
|
|
emojiContainers.append(.init(categoryName: key, emojis: value))
|
|
|
|
}
|
|
|
|
|
|
|
|
customEmojiContainer = emojiContainers
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - DropDelegate
|
|
|
|
|
|
|
|
extension StatusEditor.ViewModel: DropDelegate {
|
|
|
|
public func performDrop(info: DropInfo) -> Bool {
|
2024-01-20 18:41:44 +00:00
|
|
|
let item = info.itemProviders(for: [.image, .video, .gif, .mpeg4Movie, .quickTimeMovie, .movie])
|
2024-01-06 17:43:26 +00:00
|
|
|
processItemsProvider(items: item)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - UITextPasteDelegate
|
|
|
|
|
|
|
|
extension StatusEditor.ViewModel: UITextPasteDelegate {
|
|
|
|
public func textPasteConfigurationSupporting(
|
|
|
|
_: UITextPasteConfigurationSupporting,
|
|
|
|
transform item: UITextPasteItem
|
|
|
|
) {
|
|
|
|
if !item.itemProvider.registeredContentTypes(conformingTo: .image).isEmpty ||
|
|
|
|
!item.itemProvider.registeredContentTypes(conformingTo: .video).isEmpty ||
|
|
|
|
!item.itemProvider.registeredContentTypes(conformingTo: .gif).isEmpty
|
|
|
|
{
|
|
|
|
processItemsProvider(items: [item.itemProvider])
|
|
|
|
item.setNoResult()
|
|
|
|
} else {
|
|
|
|
item.setDefaultResult()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
extension PhotosPickerItem: @unchecked Sendable {}
|