Namespace StatusEditor

This commit is contained in:
Thomas Ricouard 2024-01-06 18:43:26 +01:00
parent 9ade571f53
commit d65510493a
46 changed files with 2998 additions and 2940 deletions

View File

@ -72,15 +72,15 @@ extension View {
Group {
switch destination {
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
StatusEditor.MainView(mode: .replyTo(status: status))
case let .newStatusEditor(visibility):
StatusEditorView(mode: .new(visibility: visibility))
StatusEditor.MainView(mode: .new(visibility: visibility))
case let .editStatusEditor(status):
StatusEditorView(mode: .edit(status: status))
StatusEditor.MainView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status))
StatusEditor.MainView(mode: .quote(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditorView(mode: .mention(account: account, visibility: visibility))
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
case .listCreate:
ListCreateView()
case let .listEdit(list):

View File

@ -69,15 +69,15 @@ extension IceCubesApp {
Group {
switch destination.wrappedValue {
case let .newStatusEditor(visibility):
StatusEditorView(mode: .new(visibility: visibility))
StatusEditor.MainView(mode: .new(visibility: visibility))
case let .editStatusEditor(status):
StatusEditorView(mode: .edit(status: status))
StatusEditor.MainView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditorView(mode: .quote(status: status))
StatusEditor.MainView(mode: .quote(status: status))
case let .replyToStatusEditor(status):
StatusEditorView(mode: .replyTo(status: status))
StatusEditor.MainView(mode: .replyTo(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditorView(mode: .mention(account: account, visibility: visibility))
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
case .none:
EmptyView()
}

View File

@ -21228,7 +21228,7 @@
},
"en" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%#@count_posts@ posts from %#@count_participants@ participants"
},
"substitutions" : {
@ -21239,13 +21239,13 @@
"plural" : {
"one" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
},
"other" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
}
@ -21259,13 +21259,13 @@
"plural" : {
"one" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
},
"other" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
}
@ -67734,7 +67734,7 @@
},
"en-GB" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%#@count_votes@ votes from %#@count_voters@ voters"
},
"substitutions" : {
@ -67745,13 +67745,13 @@
"plural" : {
"one" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
},
"other" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
}
@ -67765,13 +67765,13 @@
"plural" : {
"one" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
},
"other" : {
"stringUnit" : {
"state" : "needs_review",
"state" : "translated",
"value" : "%arg"
}
}
@ -75000,4 +75000,4 @@
}
},
"version" : "1.0"
}
}

View File

@ -26,7 +26,7 @@ class ShareViewController: UIViewController {
if let item = extensionContext?.inputItems.first as? NSExtensionItem {
if let attachments = item.attachments {
let view = StatusEditorView(mode: .shareExtension(items: attachments))
let view = StatusEditor.MainView(mode: .shareExtension(items: attachments))
.environment(UserPreferences.shared)
.environment(appAccountsManager)
.environment(client)

View File

@ -140,9 +140,9 @@ import Status
}
private func getItemImageData(item: PhotosPickerItem) async -> Data? {
guard let imageFile = try? await item.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
guard let imageFile = try? await item.loadTransferable(type: StatusEditor.ImageFileTranseferable.self) else { return nil }
let compressor = StatusEditorCompressor()
let compressor = StatusEditor.Compressor()
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
let image = UIImage(data: compressedData),

View File

@ -0,0 +1,41 @@
import Foundation
import Network
import SwiftUI
extension StatusEditor {
enum AIPrompt: CaseIterable {
case correct, fit, emphasize, addTags, insertTags
@ViewBuilder
var label: some View {
switch self {
case .correct:
Label("status.editor.ai-prompt.correct", systemImage: "text.badge.checkmark")
case .addTags:
Label("status.editor.ai-prompt.add-tags", systemImage: "number")
case .insertTags:
Label("status.editor.ai-prompt.insert-tags", systemImage: "number")
case .fit:
Label("status.editor.ai-prompt.fit", systemImage: "text.badge.minus")
case .emphasize:
Label("status.editor.ai-prompt.emphasize", systemImage: "text.badge.star")
}
}
func toRequestPrompt(text: String) -> OpenAIClient.Prompt {
switch self {
case .correct:
.correct(input: text)
case .addTags:
.addTags(input: text)
case .insertTags:
.insertTags(input: text)
case .fit:
.shorten(input: text)
case .emphasize:
.emphasize(input: text)
}
}
}
}

View File

@ -0,0 +1,466 @@
import DesignSystem
import Env
#if !os(visionOS)
import GiphyUISDK
#endif
import Models
import NukeUI
import PhotosUI
import SwiftUI
extension StatusEditor {
@MainActor
struct AccessoryView: View {
@Environment(UserPreferences.self) private var preferences
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
@Environment(\.colorScheme) private var colorScheme
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
let focusedSEVM: ViewModel
@Binding var followUpSEVMs: [ViewModel]
@State private var isDraftsSheetDisplayed: Bool = false
@State private var isLanguageSheetDisplayed: Bool = false
@State private var isCustomEmojisSheetDisplay: Bool = false
@State private var languageSearch: String = ""
@State private var isLoadingAIRequest: Bool = false
@State private var isPhotosPickerPresented: Bool = false
@State private var isFileImporterPresented: Bool = false
@State private var isCameraPickerPresented: Bool = false
@State private var isGIFPickerPresented: Bool = false
var body: some View {
@Bindable var viewModel = focusedSEVM
VStack(spacing: 0) {
#if os(visionOS)
HStack {
contentView
}
.frame(height: 24)
.padding(16)
.background(.thinMaterial)
.cornerRadius(8)
#else
Divider()
HStack {
contentView
}
.frame(height: 20)
.padding(.vertical, 12)
.background(.thinMaterial)
#endif
}
.onAppear {
viewModel.setInitialLanguageSelection(preference: preferences.recentlyUsedLanguages.first ?? preferences.serverPreferences?.postLanguage)
}
}
@ViewBuilder
private var contentView: some View {
#if os(visionOS)
HStack(spacing: 8) {
actionsView
}
#else
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 16) {
actionsView
}
.padding(.horizontal, .layoutPadding)
}
Spacer()
#endif
}
@ViewBuilder
private var actionsView: some View {
@Bindable var viewModel = focusedSEVM
Menu {
Button {
isPhotosPickerPresented = true
} label: {
Label("status.editor.photo-library", systemImage: "photo")
}
#if !targetEnvironment(macCatalyst)
Button {
isCameraPickerPresented = true
} label: {
Label("status.editor.camera-picker", systemImage: "camera")
}
#endif
Button {
isFileImporterPresented = true
} label: {
Label("status.editor.browse-file", systemImage: "folder")
}
#if !os(visionOS)
Button {
isGIFPickerPresented = true
} label: {
Label("GIPHY", systemImage: "party.popper")
}
#endif
} label: {
if viewModel.isMediasLoading {
ProgressView()
} else {
Image(systemName: "photo.on.rectangle.angled")
}
}
.photosPicker(isPresented: $isPhotosPickerPresented,
selection: $viewModel.mediaPickers,
maxSelectionCount: 4,
matching: .any(of: [.images, .videos]),
photoLibrary: .shared())
.fileImporter(isPresented: $isFileImporterPresented,
allowedContentTypes: [.image, .video],
allowsMultipleSelection: true)
{ result in
if let urls = try? result.get() {
viewModel.processURLs(urls: urls)
}
}
.fullScreenCover(isPresented: $isCameraPickerPresented, content: {
CameraPickerView(selectedImage: .init(get: {
nil
}, set: { image in
if let image {
viewModel.processCameraPhoto(image: image)
}
}))
.background(.black)
})
.sheet(isPresented: $isGIFPickerPresented, content: {
#if !os(visionOS)
#if targetEnvironment(macCatalyst)
NavigationStack {
giphyView
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
isGIFPickerPresented = false
} label: {
Image(systemName: "xmark.circle")
}
}
}
}
.presentationDetents([.medium, .large])
#else
giphyView
.presentationDetents([.medium, .large])
#endif
#else
EmptyView()
#endif
})
.accessibilityLabel("accessibility.editor.button.attach-photo")
.disabled(viewModel.showPoll)
Button {
// all SEVM have the same visibility value
followUpSEVMs.append(ViewModel(mode: .new(visibility: focusedSEVM.visibility)))
} label: {
Image(systemName: "arrowshape.turn.up.left.circle.fill")
}
.disabled(!canAddNewSEVM)
Button {
withAnimation {
viewModel.showPoll.toggle()
viewModel.resetPollDefaults()
}
} label: {
Image(systemName: "chart.bar")
}
.accessibilityLabel("accessibility.editor.button.poll")
.disabled(viewModel.shouldDisablePollButton)
Button {
withAnimation {
viewModel.spoilerOn.toggle()
}
isSpoilerTextFocused = viewModel.id
} label: {
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle")
}
.accessibilityLabel("accessibility.editor.button.spoiler")
if !viewModel.mode.isInShareExtension {
Button {
isDraftsSheetDisplayed = true
} label: {
Image(systemName: "archivebox")
}
.accessibilityLabel("accessibility.editor.button.drafts")
.popover(isPresented: $isDraftsSheetDisplayed) {
if UIDevice.current.userInterfaceIdiom == .phone {
draftsListView
.presentationDetents([.medium])
} else {
draftsListView
.frame(width: 400, height: 500)
}
}
}
if !viewModel.customEmojiContainer.isEmpty {
Button {
isCustomEmojisSheetDisplay = true
} label: {
// This is a workaround for an apparent bug in the `face.smiling` SF Symbol.
// See https://github.com/Dimillian/IceCubesApp/issues/1193
let customEmojiSheetIconName = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
Image(systemName: customEmojiSheetIconName)
}
.accessibilityLabel("accessibility.editor.button.custom-emojis")
.popover(isPresented: $isCustomEmojisSheetDisplay) {
if UIDevice.current.userInterfaceIdiom == .phone {
customEmojisSheet
} else {
customEmojisSheet
.frame(width: 400, height: 500)
}
}
}
Button {
viewModel.insertStatusText(text: "#")
} label: {
Image(systemName: "number")
}
Button {
viewModel.insertStatusText(text: "@")
} label: {
Image(systemName: "at")
}
Button {
isLanguageSheetDisplayed.toggle()
} label: {
if let language = viewModel.selectedLanguage {
Text(language.uppercased())
} else {
Image(systemName: "globe")
}
}
.accessibilityLabel("accessibility.editor.button.language")
.popover(isPresented: $isLanguageSheetDisplayed) {
if UIDevice.current.userInterfaceIdiom == .phone {
languageSheetView
} else {
languageSheetView
.frame(width: 400, height: 500)
}
}
if preferences.isOpenAIEnabled {
AIMenu.disabled(!viewModel.canPost)
}
}
private var canAddNewSEVM: Bool {
guard followUpSEVMs.count < 5 else { return false }
if followUpSEVMs.isEmpty, // there is only mainSEVM on the editor
!focusedSEVM.statusText.string.isEmpty // focusedSEVM is also mainSEVM
{ return true }
if let lastSEVMs = followUpSEVMs.last,
!lastSEVMs.statusText.string.isEmpty
{ return true }
return false
}
#if !os(visionOS)
@ViewBuilder
private var giphyView: some View {
@Bindable var viewModel = focusedSEVM
GifPickerView { url in
GPHCache.shared.downloadAssetData(url) { data, _ in
guard let data else { return }
viewModel.processGIFData(data: data)
}
isGIFPickerPresented = false
} onShouldDismissGifPicker: {
isGIFPickerPresented = false
}
}
#endif
private var draftsListView: some View {
DraftsListView(selectedDraft: .init(get: {
nil
}, set: { draft in
if let draft {
focusedSEVM.insertStatusText(text: draft.content)
}
}))
}
@ViewBuilder
private func languageTextView(isoCode: String, nativeName: String?, name: String?) -> some View {
if let nativeName, let name {
Text("\(nativeName) (\(name))")
} else {
Text(isoCode.uppercased())
}
}
private var AIMenu: some View {
Menu {
ForEach(AIPrompt.allCases, id: \.self) { prompt in
Button {
Task {
isLoadingAIRequest = true
await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string))
isLoadingAIRequest = false
}
} label: {
prompt.label
}
}
if let backup = focusedSEVM.backupStatusText {
Button {
focusedSEVM.replaceTextWith(text: backup.string)
focusedSEVM.backupStatusText = nil
} label: {
Label("status.editor.restore-previous", systemImage: "arrow.uturn.right")
}
}
} label: {
if isLoadingAIRequest {
ProgressView()
} else {
Image(systemName: "faxmachine")
.accessibilityLabel("accessibility.editor.button.ai-prompt")
}
}
}
private var languageSheetView: some View {
NavigationStack {
List {
if languageSearch.isEmpty {
if !recentlyUsedLanguages.isEmpty {
Section("status.editor.language-select.recently-used") {
languageSheetSection(languages: recentlyUsedLanguages)
}
}
Section {
languageSheetSection(languages: otherLanguages)
}
} else {
languageSheetSection(languages: languageSearchResult(query: languageSearch))
}
}
.searchable(text: $languageSearch)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { isLanguageSheetDisplayed = false })
}
}
.navigationTitle("status.editor.language-select.navigation-title")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
}
private func languageSheetSection(languages: [Language]) -> some View {
ForEach(languages) { language in
HStack {
languageTextView(
isoCode: language.isoCode,
nativeName: language.nativeName,
name: language.localizedName
).tag(language.isoCode)
Spacer()
if language.isoCode == focusedSEVM.selectedLanguage {
Image(systemName: "checkmark")
}
}
.listRowBackground(theme.primaryBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
focusedSEVM.selectedLanguage = language.isoCode
focusedSEVM.hasExplicitlySelectedLanguage = true
isLanguageSheetDisplayed = false
}
}
}
private var customEmojisSheet: some View {
NavigationStack {
ScrollView {
ForEach(focusedSEVM.customEmojiContainer) { container in
VStack(alignment: .leading) {
Text(container.categoryName)
.font(.scaledFootnote)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 9) {
ForEach(container.emojis) { emoji in
LazyImage(url: emoji.url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.accessibilityLabel(emoji.shortcode.replacingOccurrences(of: "_", with: " "))
.accessibilityAddTraits(.isButton)
} else if state.isLoading {
Rectangle()
.fill(Color.gray)
.frame(width: 40, height: 40)
.accessibility(hidden: true)
.shimmering()
}
}
.onTapGesture {
focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ")
}
}
}
}
.padding(.horizontal)
.padding(.bottom)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { isCustomEmojisSheetDisplay = false })
}
}
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.navigationTitle("status.editor.emojis.navigation-title")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium])
}
private var recentlyUsedLanguages: [Language] {
preferences.recentlyUsedLanguages.compactMap { isoCode in
Language.allAvailableLanguages.first { $0.isoCode == isoCode }
}
}
private var otherLanguages: [Language] {
Language.allAvailableLanguages.filter { !preferences.recentlyUsedLanguages.contains($0.isoCode) }
}
private func languageSearchResult(query: String) -> [Language] {
Language.allAvailableLanguages.filter { language in
guard !languageSearch.isEmpty else {
return true
}
return language.nativeName?.lowercased().hasPrefix(query.lowercased()) == true
|| language.localizedName?.lowercased().hasPrefix(query.lowercased()) == true
}
}
}
}

View File

@ -0,0 +1,64 @@
import DesignSystem
import EmojiText
import Foundation
import SwiftUI
import Models
import SwiftData
extension StatusEditor {
@MainActor
struct AutoCompleteView: View {
@Environment(\.modelContext) var context
@Environment(Theme.self) var theme
var viewModel: ViewModel
@State private var isTagSuggestionExpanded: Bool = false
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
var body: some View {
if !viewModel.mentionsSuggestions.isEmpty ||
!viewModel.tagsSuggestions.isEmpty ||
(viewModel.showRecentsTagsInline && !recentTags.isEmpty) {
VStack {
HStack {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
if !viewModel.mentionsSuggestions.isEmpty {
Self.MentionsView(viewModel: viewModel)
} else {
if viewModel.showRecentsTagsInline {
Self.RecentTagsView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
} else {
Self.RemoteTagsView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
}
}
}
.padding(.horizontal, .layoutPadding)
}
.scrollContentBackground(.hidden)
if viewModel.mentionsSuggestions.isEmpty {
Spacer()
Button {
withAnimation {
isTagSuggestionExpanded.toggle()
}
} label: {
Image(systemName: isTagSuggestionExpanded ? "chevron.down.circle" : "chevron.up.circle")
.padding(.trailing, 8)
}
}
}
.frame(height: 40)
if isTagSuggestionExpanded {
Self.ExpandedView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
}
}
.background(.thinMaterial)
}
}
}
}

View File

@ -6,15 +6,14 @@ import Models
import SwiftData
import Env
extension StatusEditorAutoCompleteView {
extension StatusEditor.AutoCompleteView {
@MainActor
struct ExpandedView: View {
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
@Environment(CurrentAccount.self) private var currentAccount
var viewModel: StatusEditorViewModel
var viewModel: StatusEditor.ViewModel
@Binding var isTagSuggestionExpanded: Bool
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]

View File

@ -6,11 +6,11 @@ import Models
import SwiftData
extension StatusEditorAutoCompleteView {
extension StatusEditor.AutoCompleteView {
struct MentionsView: View {
@Environment(Theme.self) private var theme
var viewModel: StatusEditorViewModel
var viewModel: StatusEditor.ViewModel
var body: some View {
ForEach(viewModel.mentionsSuggestions) { account in

View File

@ -6,11 +6,11 @@ import Models
import SwiftData
extension StatusEditorAutoCompleteView {
extension StatusEditor.AutoCompleteView {
struct RecentTagsView: View {
@Environment(Theme.self) private var theme
var viewModel: StatusEditorViewModel
var viewModel: StatusEditor.ViewModel
@Binding var isTagSuggestionExpanded: Bool
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]

View File

@ -6,12 +6,12 @@ import Models
import SwiftData
extension StatusEditorAutoCompleteView {
extension StatusEditor.AutoCompleteView {
struct RemoteTagsView: View {
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
var viewModel: StatusEditorViewModel
var viewModel: StatusEditor.ViewModel
@Binding var isTagSuggestionExpanded: Bool
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]

View File

@ -1,61 +0,0 @@
import DesignSystem
import EmojiText
import Foundation
import SwiftUI
import Models
import SwiftData
@MainActor
struct StatusEditorAutoCompleteView: View {
@Environment(\.modelContext) var context
@Environment(Theme.self) var theme
var viewModel: StatusEditorViewModel
@State private var isTagSuggestionExpanded: Bool = false
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
var body: some View {
if !viewModel.mentionsSuggestions.isEmpty ||
!viewModel.tagsSuggestions.isEmpty ||
(viewModel.showRecentsTagsInline && !recentTags.isEmpty) {
VStack {
HStack {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
if !viewModel.mentionsSuggestions.isEmpty {
Self.MentionsView(viewModel: viewModel)
} else {
if viewModel.showRecentsTagsInline {
Self.RecentTagsView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
} else {
Self.RemoteTagsView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
}
}
}
.padding(.horizontal, .layoutPadding)
}
.scrollContentBackground(.hidden)
if viewModel.mentionsSuggestions.isEmpty {
Spacer()
Button {
withAnimation {
isTagSuggestionExpanded.toggle()
}
} label: {
Image(systemName: isTagSuggestionExpanded ? "chevron.down.circle" : "chevron.up.circle")
.padding(.trailing, 8)
}
}
}
.frame(height: 40)
if isTagSuggestionExpanded {
Self.ExpandedView(viewModel: viewModel, isTagSuggestionExpanded: $isTagSuggestionExpanded)
}
}
.background(.thinMaterial)
}
}
}

View File

@ -0,0 +1,39 @@
import SwiftUI
import UIKit
extension StatusEditor {
struct CameraPickerView: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.dismiss) var dismiss
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let picker: CameraPickerView
init(picker: CameraPickerView) {
self.picker = picker
}
func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
guard let selectedImage = info[.originalImage] as? UIImage else { return }
picker.selectedImage = selectedImage
picker.dismiss()
}
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
#if !os(visionOS)
imagePicker.sourceType = .camera
#endif
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(_: UIImagePickerController, context _: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(picker: self)
}
}
}

View File

@ -0,0 +1,11 @@
import Foundation
import Models
extension StatusEditor {
struct CategorizedEmojiContainer: Identifiable, Equatable {
let id = UUID().uuidString
let categoryName: String
var emojis: [Emoji]
}
}

View File

@ -0,0 +1,105 @@
import AVFoundation
import Foundation
import UIKit
extension StatusEditor {
public actor Compressor {
public init() { }
enum CompressorError: Error {
case noData
}
public func compressImageFrom(url: URL) async -> Data? {
await withCheckedContinuation { continuation in
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
continuation.resume(returning: nil)
return
}
let maxPixelSize: Int = if Bundle.main.bundlePath.hasSuffix(".appex") {
1536
} else {
4096
}
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
] as [CFString: Any] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else {
continuation.resume(returning: nil)
return
}
let data = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
continuation.resume(returning: nil)
return
}
let isPNG: Bool = {
guard let utType = cgImage.utType else { return false }
return (utType as String) == UTType.png.identifier
}()
let destinationProperties = [
kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75,
] as CFDictionary
CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties)
CGImageDestinationFinalize(imageDestination)
continuation.resume(returning: data as Data)
}
}
public func compressImageForUpload(_ image: UIImage) async throws -> Data {
var image = image
if image.size.height > 5000 || image.size.width > 5000 {
image = image.resized(to: .init(width: image.size.width / 4,
height: image.size.height / 4))
}
guard var imageData = image.jpegData(compressionQuality: 0.8) else {
throw CompressorError.noData
}
let maxSize = 10 * 1024 * 1024
if imageData.count > maxSize {
while imageData.count > maxSize {
guard let compressedImage = UIImage(data: imageData),
let compressedData = compressedImage.jpegData(compressionQuality: 0.8)
else {
throw CompressorError.noData
}
imageData = compressedData
}
}
return imageData
}
func compressVideo(_ url: URL) async -> URL? {
await withCheckedContinuation { continuation in
let urlAsset = AVURLAsset(url: url, options: nil)
guard let exportSession = AVAssetExportSession(asset: urlAsset, presetName: AVAssetExportPreset1920x1080) else {
continuation.resume(returning: nil)
return
}
let outputURL = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(url.pathExtension)")
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
exportSession.exportAsynchronously { () in
continuation.resume(returning: outputURL)
}
}
}
}
}

View File

@ -0,0 +1,17 @@
import Foundation
import Models
import PhotosUI
import SwiftUI
import UIKit
extension StatusEditor {
struct MediaContainer: Identifiable {
let id: String
let image: UIImage?
let movieTransferable: MovieFileTranseferable?
let gifTransferable: GifFileTranseferable?
let mediaAttachment: MediaAttachment?
let error: Error?
}
}

View File

@ -0,0 +1,181 @@
import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
extension StatusEditor {
@MainActor
struct MediaEditView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
@Environment(UserPreferences.self) private var preferences
var viewModel: ViewModel
let container: StatusEditor.MediaContainer
@State private var imageDescription: String = ""
@FocusState private var isFieldFocused: Bool
@State private var isUpdating: Bool = false
@State private var didAppear: Bool = false
@State private var isGeneratingDescription: Bool = false
@State private var showTranslateButton: Bool = false
@State private var isTranslating: Bool = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("status.editor.media.image-description",
text: $imageDescription,
axis: .vertical)
.focused($isFieldFocused)
generateButton
translateButton
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
if let url = container.mediaAttachment?.url {
AsyncImage(
url: url,
content: { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.cornerRadius(8)
.padding(8)
},
placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray)
.frame(height: 200)
.shimmering()
}
)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.onAppear {
if !didAppear {
imageDescription = container.mediaAttachment?.description ?? ""
isFieldFocused = true
didAppear = true
}
}
.navigationTitle("status.editor.media.edit-image")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if !imageDescription.isEmpty {
isUpdating = true
if currentInstance.isEditAltTextSupported, viewModel.mode.isEditing {
Task {
await viewModel.editDescription(container: container, description: imageDescription)
dismiss()
isUpdating = false
}
} else {
Task {
await viewModel.addDescription(container: container, description: imageDescription)
dismiss()
isUpdating = false
}
}
}
} label: {
if isUpdating {
ProgressView()
} else {
Text("action.done")
}
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel") {
dismiss()
}
}
}
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
}
}
@ViewBuilder
private var generateButton: some View {
if let url = container.mediaAttachment?.url, preferences.isOpenAIEnabled {
Button {
Task {
if let description = await generateDescription(url: url) {
imageDescription = description
let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
if lang != nil, lang != "en" {
withAnimation {
showTranslateButton = true
}
}
}
}
} label: {
if isGeneratingDescription {
ProgressView()
} else {
Text("status.editor.media.generate-description")
}
}
}
}
@ViewBuilder
private var translateButton: some View {
if showTranslateButton {
Button {
Task {
if let description = await translateDescription() {
imageDescription = description
withAnimation {
showTranslateButton = false
}
}
}
} label: {
if isTranslating {
ProgressView()
} else {
Text("status.action.translate")
}
}
}
}
private func generateDescription(url: URL) async -> String? {
isGeneratingDescription = true
let client = OpenAIClient()
let response = try? await client.request(.imageDescription(image: url))
isGeneratingDescription = false
return response?.trimmedText
}
private func translateDescription() async -> String? {
isTranslating = true
let userAPIKey = DeepLUserAPIHandler.readIfAllowed()
let userAPIFree = UserPreferences.shared.userDeeplAPIFree
let deeplClient = DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIFree)
let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
guard let lang else { return nil }
let translation = try? await deeplClient.request(target: lang, text: imageDescription)
isTranslating = false
return translation?.content.asRawText
}
}
}

View File

@ -0,0 +1,252 @@
import AVKit
import DesignSystem
import Env
import MediaUI
import Models
import NukeUI
import SwiftUI
extension StatusEditor {
@MainActor
struct MediaView: View {
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
var viewModel: ViewModel
@Binding var editingMediaContainer: MediaContainer?
@State private var isErrorDisplayed: Bool = false
@Namespace var mediaSpace
@State private var scrollID: String?
var body: some View {
ScrollView(.horizontal, showsIndicators: showsScrollIndicators) {
switch count {
case 1: mediaLayout
case 2: mediaLayout
case 3: mediaLayout
case 4: mediaLayout
default: mediaLayout
}
}
.scrollPosition(id: $scrollID, anchor: .trailing)
.padding(.horizontal, .layoutPadding)
.frame(height: count > 0 ? containerHeight : 0)
.animation(.spring(duration: 0.3), value: count)
.onChange(of: count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.5))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = containers.last?.id
}
}
}
}
}
private var count: Int { viewModel.mediaContainers.count }
private var containers: [MediaContainer] { viewModel.mediaContainers }
private let containerHeight: CGFloat = 300
private var containerWidth: CGFloat { containerHeight / 1.5 }
#if targetEnvironment(macCatalyst)
private var showsScrollIndicators: Bool { count > 1 }
private var scrollBottomPadding: CGFloat?
#else
private var showsScrollIndicators: Bool = false
private var scrollBottomPadding: CGFloat? = 0
#endif
init(viewModel: ViewModel, editingMediaContainer: Binding<StatusEditor.MediaContainer?>) {
self.viewModel = viewModel
_editingMediaContainer = editingMediaContainer
}
private func pixel(at index: Int) -> some View {
Rectangle().frame(width: 0, height: 0)
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
}
private var mediaLayout: some View {
HStack(alignment: .center, spacing: count > 1 ? 8 : 0) {
if count > 0 {
if count == 1 {
makeMediaItem(at: 0)
.containerRelativeFrame(.horizontal, alignment: .leading)
} else {
makeMediaItem(at: 0)
}
} else { pixel(at: 0) }
if count > 1 { makeMediaItem(at: 1) } else { pixel(at: 1) }
if count > 2 { makeMediaItem(at: 2) } else { pixel(at: 2) }
if count > 3 { makeMediaItem(at: 3) } else { pixel(at: 3) }
}
.padding(.bottom, scrollBottomPadding)
.scrollTargetLayout()
}
private func makeMediaItem(at index: Int) -> some View {
let container = viewModel.mediaContainers[index]
return Menu {
makeImageMenu(container: container)
} label: {
RoundedRectangle(cornerRadius: 8).fill(.clear)
.overlay {
if let attachement = container.mediaAttachment {
makeRemoteMediaView(mediaAttachement: attachement)
} else if container.image != nil {
makeLocalImageView(container: container)
} else if let error = container.error as? ServerError {
makeErrorView(error: error)
} else {
placeholderView
}
}
}
.overlay(alignment: .bottomTrailing) {
makeAltMarker(container: container)
}
.overlay(alignment: .topTrailing) {
makeDiscardMarker(container: container)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minWidth: count == 1 ? nil : containerWidth, maxWidth: 600)
.id(container.id)
.matchedGeometryEffect(id: container.id, in: mediaSpace, anchor: .leading)
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
}
private func makeLocalImageView(container: MediaContainer) -> some View {
ZStack(alignment: .center) {
Image(uiImage: container.image!)
.resizable()
.blur(radius: container.mediaAttachment == nil ? 20 : 0)
.scaledToFill()
.cornerRadius(8)
if container.error != nil {
Text("status.editor.error.upload")
} else if container.mediaAttachment == nil {
ProgressView()
}
}
}
private func makeRemoteMediaView(mediaAttachement: MediaAttachment) -> some View {
ZStack(alignment: .center) {
switch mediaAttachement.supportedType {
case .gifv, .video, .audio:
if let url = mediaAttachement.url {
MediaUIAttachmentVideoView(viewModel: .init(url: url, forceAutoPlay: true))
} else {
placeholderView
}
case .image:
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizable()
.scaledToFill()
} else {
placeholderView
}
}
}
case .none:
EmptyView()
}
}
.cornerRadius(8)
}
@ViewBuilder
private func makeImageMenu(container: MediaContainer) -> some View {
if container.mediaAttachment?.url != nil {
if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing {
Button {
editingMediaContainer = container
} label: {
Label(container.mediaAttachment?.description?.isEmpty == false ?
"status.editor.description.edit" : "status.editor.description.add",
systemImage: "pencil.line")
}
}
} else if container.error != nil {
Button {
isErrorDisplayed = true
} label: {
Label("action.view.error", systemImage: "exclamationmark.triangle")
}
}
Button(role: .destructive) {
deleteAction(container: container)
} label: {
Label("action.delete", systemImage: "trash")
}
}
private func makeErrorView(error: ServerError) -> some View {
ZStack {
placeholderView
Text("status.editor.error.upload")
}
.alert("alert.error", isPresented: $isErrorDisplayed) {
Button("Ok", action: {})
} message: {
Text(error.error ?? "")
}
}
private func makeAltMarker(container: MediaContainer) -> some View {
Button {
editingMediaContainer = container
} label: {
Text("status.image.alt-text.abbreviation")
.font(.caption2)
}
.padding(8)
.background(.thinMaterial)
.cornerRadius(8)
.padding(4)
}
private func makeDiscardMarker(container: MediaContainer) -> some View {
Button(role: .destructive) {
deleteAction(container: container)
} label: {
Image(systemName: "xmark")
.font(.caption2)
.foregroundStyle(.tint)
.padding(8)
.background(Circle().fill(.thinMaterial))
}
.padding(4)
}
private func deleteAction(container: MediaContainer) {
viewModel.mediaPickers.removeAll(where: {
if let id = $0.itemIdentifier {
return id == container.id
}
return false
})
viewModel.mediaContainers.removeAll {
$0.id == container.id
}
}
private var placeholderView: some View {
ZStack(alignment: .center) {
Rectangle()
.foregroundColor(theme.secondaryBackgroundColor)
.accessibilityHidden(true)
ProgressView()
}
.cornerRadius(8)
}
}
}

View File

@ -0,0 +1,125 @@
import DesignSystem
import Env
import SwiftUI
extension StatusEditor {
@MainActor
struct PollView: View {
enum FocusField: Hashable {
case option(Int)
}
@FocusState var focused: FocusField?
@State private var currentFocusIndex: Int = 0
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
var viewModel: ViewModel
@Binding var showPoll: Bool
var body: some View {
@Bindable var viewModel = viewModel
let count = viewModel.pollOptions.count
VStack {
ForEach(0 ..< count, id: \.self) { index in
VStack {
HStack(spacing: 16) {
TextField("status.poll.option-n \(index + 1)", text: $viewModel.pollOptions[index])
.textFieldStyle(.roundedBorder)
.focused($focused, equals: .option(index))
.onTapGesture {
if canAddMoreAt(index) {
currentFocusIndex = index
}
}
.onSubmit {
if canAddMoreAt(index) {
addChoice(at: index)
}
}
if canAddMoreAt(index) {
Button {
addChoice(at: index)
} label: {
Image(systemName: "plus.circle.fill")
}
} else {
Button {
removeChoice(at: index)
} label: {
Image(systemName: "minus.circle.fill")
}
}
}
.padding(.horizontal)
.padding(.top)
}
}
.onAppear {
focused = .option(0)
}
HStack {
Picker("status.poll.frequency", selection: $viewModel.pollVotingFrequency) {
ForEach(PollVotingFrequency.allCases, id: \.rawValue) {
Text($0.displayString)
.tag($0)
}
}
.layoutPriority(1.0)
Spacer()
Picker("status.poll.duration", selection: $viewModel.pollDuration) {
ForEach(Duration.pollDurations(), id: \.rawValue) {
Text($0.description)
.tag($0)
}
}
}
.padding(.horizontal)
}
.background(
RoundedRectangle(cornerRadius: 6.0)
.stroke(theme.secondaryBackgroundColor.opacity(0.6), lineWidth: 1)
.background(theme.primaryBackgroundColor.opacity(0.3))
)
}
private func addChoice(at index: Int) {
viewModel.pollOptions.append("")
currentFocusIndex = index + 1
moveFocus()
}
private func removeChoice(at index: Int) {
viewModel.pollOptions.remove(at: index)
if viewModel.pollOptions.count == 1 {
viewModel.resetPollDefaults()
withAnimation {
showPoll = false
}
}
}
private func moveFocus() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
focused = .option(currentFocusIndex)
}
}
private func canAddMoreAt(_ index: Int) -> Bool {
let count = viewModel.pollOptions.count
let maxEntries: Int = currentInstance.instance?.configuration?.polls.maxOptions ?? 4
return index == count - 1 && count < maxEntries
}
}
}

View File

@ -1,38 +0,0 @@
import Foundation
import Network
import SwiftUI
enum StatusEditorAIPrompt: CaseIterable {
case correct, fit, emphasize, addTags, insertTags
@ViewBuilder
var label: some View {
switch self {
case .correct:
Label("status.editor.ai-prompt.correct", systemImage: "text.badge.checkmark")
case .addTags:
Label("status.editor.ai-prompt.add-tags", systemImage: "number")
case .insertTags:
Label("status.editor.ai-prompt.insert-tags", systemImage: "number")
case .fit:
Label("status.editor.ai-prompt.fit", systemImage: "text.badge.minus")
case .emphasize:
Label("status.editor.ai-prompt.emphasize", systemImage: "text.badge.star")
}
}
func toRequestPrompt(text: String) -> OpenAIClient.Prompt {
switch self {
case .correct:
.correct(input: text)
case .addTags:
.addTags(input: text)
case .insertTags:
.insertTags(input: text)
case .fit:
.shorten(input: text)
case .emphasize:
.emphasize(input: text)
}
}
}

View File

@ -1,463 +0,0 @@
import DesignSystem
import Env
#if !os(visionOS)
import GiphyUISDK
#endif
import Models
import NukeUI
import PhotosUI
import SwiftUI
@MainActor
struct StatusEditorAccessoryView: View {
@Environment(UserPreferences.self) private var preferences
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
@Environment(\.colorScheme) private var colorScheme
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
let focusedSEVM: StatusEditorViewModel
@Binding var followUpSEVMs: [StatusEditorViewModel]
@State private var isDraftsSheetDisplayed: Bool = false
@State private var isLanguageSheetDisplayed: Bool = false
@State private var isCustomEmojisSheetDisplay: Bool = false
@State private var languageSearch: String = ""
@State private var isLoadingAIRequest: Bool = false
@State private var isPhotosPickerPresented: Bool = false
@State private var isFileImporterPresented: Bool = false
@State private var isCameraPickerPresented: Bool = false
@State private var isGIFPickerPresented: Bool = false
var body: some View {
@Bindable var viewModel = focusedSEVM
VStack(spacing: 0) {
#if os(visionOS)
HStack {
contentView
}
.frame(height: 24)
.padding(16)
.background(.thinMaterial)
.cornerRadius(8)
#else
Divider()
HStack {
contentView
}
.frame(height: 20)
.padding(.vertical, 12)
.background(.thinMaterial)
#endif
}
.onAppear {
viewModel.setInitialLanguageSelection(preference: preferences.recentlyUsedLanguages.first ?? preferences.serverPreferences?.postLanguage)
}
}
@ViewBuilder
private var contentView: some View {
#if os(visionOS)
HStack(spacing: 8) {
actionsView
}
#else
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 16) {
actionsView
}
.padding(.horizontal, .layoutPadding)
}
Spacer()
#endif
}
@ViewBuilder
private var actionsView: some View {
@Bindable var viewModel = focusedSEVM
Menu {
Button {
isPhotosPickerPresented = true
} label: {
Label("status.editor.photo-library", systemImage: "photo")
}
#if !targetEnvironment(macCatalyst)
Button {
isCameraPickerPresented = true
} label: {
Label("status.editor.camera-picker", systemImage: "camera")
}
#endif
Button {
isFileImporterPresented = true
} label: {
Label("status.editor.browse-file", systemImage: "folder")
}
#if !os(visionOS)
Button {
isGIFPickerPresented = true
} label: {
Label("GIPHY", systemImage: "party.popper")
}
#endif
} label: {
if viewModel.isMediasLoading {
ProgressView()
} else {
Image(systemName: "photo.on.rectangle.angled")
}
}
.photosPicker(isPresented: $isPhotosPickerPresented,
selection: $viewModel.mediaPickers,
maxSelectionCount: 4,
matching: .any(of: [.images, .videos]),
photoLibrary: .shared())
.fileImporter(isPresented: $isFileImporterPresented,
allowedContentTypes: [.image, .video],
allowsMultipleSelection: true)
{ result in
if let urls = try? result.get() {
viewModel.processURLs(urls: urls)
}
}
.fullScreenCover(isPresented: $isCameraPickerPresented, content: {
StatusEditorCameraPickerView(selectedImage: .init(get: {
nil
}, set: { image in
if let image {
viewModel.processCameraPhoto(image: image)
}
}))
.background(.black)
})
.sheet(isPresented: $isGIFPickerPresented, content: {
#if !os(visionOS)
#if targetEnvironment(macCatalyst)
NavigationStack {
giphyView
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
isGIFPickerPresented = false
} label: {
Image(systemName: "xmark.circle")
}
}
}
}
.presentationDetents([.medium, .large])
#else
giphyView
.presentationDetents([.medium, .large])
#endif
#else
EmptyView()
#endif
})
.accessibilityLabel("accessibility.editor.button.attach-photo")
.disabled(viewModel.showPoll)
Button {
// all SEVM have the same visibility value
followUpSEVMs.append(StatusEditorViewModel(mode: .new(visibility: focusedSEVM.visibility)))
} label: {
Image(systemName: "arrowshape.turn.up.left.circle.fill")
}
.disabled(!canAddNewSEVM)
Button {
withAnimation {
viewModel.showPoll.toggle()
viewModel.resetPollDefaults()
}
} label: {
Image(systemName: "chart.bar")
}
.accessibilityLabel("accessibility.editor.button.poll")
.disabled(viewModel.shouldDisablePollButton)
Button {
withAnimation {
viewModel.spoilerOn.toggle()
}
isSpoilerTextFocused = viewModel.id
} label: {
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle")
}
.accessibilityLabel("accessibility.editor.button.spoiler")
if !viewModel.mode.isInShareExtension {
Button {
isDraftsSheetDisplayed = true
} label: {
Image(systemName: "archivebox")
}
.accessibilityLabel("accessibility.editor.button.drafts")
.popover(isPresented: $isDraftsSheetDisplayed) {
if UIDevice.current.userInterfaceIdiom == .phone {
draftsListView
.presentationDetents([.medium])
} else {
draftsListView
.frame(width: 400, height: 500)
}
}
}
if !viewModel.customEmojiContainer.isEmpty {
Button {
isCustomEmojisSheetDisplay = true
} label: {
// This is a workaround for an apparent bug in the `face.smiling` SF Symbol.
// See https://github.com/Dimillian/IceCubesApp/issues/1193
let customEmojiSheetIconName = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
Image(systemName: customEmojiSheetIconName)
}
.accessibilityLabel("accessibility.editor.button.custom-emojis")
.popover(isPresented: $isCustomEmojisSheetDisplay) {
if UIDevice.current.userInterfaceIdiom == .phone {
customEmojisSheet
} else {
customEmojisSheet
.frame(width: 400, height: 500)
}
}
}
Button {
viewModel.insertStatusText(text: "#")
} label: {
Image(systemName: "number")
}
Button {
viewModel.insertStatusText(text: "@")
} label: {
Image(systemName: "at")
}
Button {
isLanguageSheetDisplayed.toggle()
} label: {
if let language = viewModel.selectedLanguage {
Text(language.uppercased())
} else {
Image(systemName: "globe")
}
}
.accessibilityLabel("accessibility.editor.button.language")
.popover(isPresented: $isLanguageSheetDisplayed) {
if UIDevice.current.userInterfaceIdiom == .phone {
languageSheetView
} else {
languageSheetView
.frame(width: 400, height: 500)
}
}
if preferences.isOpenAIEnabled {
AIMenu.disabled(!viewModel.canPost)
}
}
private var canAddNewSEVM: Bool {
guard followUpSEVMs.count < 5 else { return false }
if followUpSEVMs.isEmpty, // there is only mainSEVM on the editor
!focusedSEVM.statusText.string.isEmpty // focusedSEVM is also mainSEVM
{ return true }
if let lastSEVMs = followUpSEVMs.last,
!lastSEVMs.statusText.string.isEmpty
{ return true }
return false
}
#if !os(visionOS)
@ViewBuilder
private var giphyView: some View {
@Bindable var viewModel = focusedSEVM
GifPickerView { url in
GPHCache.shared.downloadAssetData(url) { data, _ in
guard let data else { return }
viewModel.processGIFData(data: data)
}
isGIFPickerPresented = false
} onShouldDismissGifPicker: {
isGIFPickerPresented = false
}
}
#endif
private var draftsListView: some View {
DraftsListView(selectedDraft: .init(get: {
nil
}, set: { draft in
if let draft {
focusedSEVM.insertStatusText(text: draft.content)
}
}))
}
@ViewBuilder
private func languageTextView(isoCode: String, nativeName: String?, name: String?) -> some View {
if let nativeName, let name {
Text("\(nativeName) (\(name))")
} else {
Text(isoCode.uppercased())
}
}
private var AIMenu: some View {
Menu {
ForEach(StatusEditorAIPrompt.allCases, id: \.self) { prompt in
Button {
Task {
isLoadingAIRequest = true
await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string))
isLoadingAIRequest = false
}
} label: {
prompt.label
}
}
if let backup = focusedSEVM.backupStatusText {
Button {
focusedSEVM.replaceTextWith(text: backup.string)
focusedSEVM.backupStatusText = nil
} label: {
Label("status.editor.restore-previous", systemImage: "arrow.uturn.right")
}
}
} label: {
if isLoadingAIRequest {
ProgressView()
} else {
Image(systemName: "faxmachine")
.accessibilityLabel("accessibility.editor.button.ai-prompt")
}
}
}
private var languageSheetView: some View {
NavigationStack {
List {
if languageSearch.isEmpty {
if !recentlyUsedLanguages.isEmpty {
Section("status.editor.language-select.recently-used") {
languageSheetSection(languages: recentlyUsedLanguages)
}
}
Section {
languageSheetSection(languages: otherLanguages)
}
} else {
languageSheetSection(languages: languageSearchResult(query: languageSearch))
}
}
.searchable(text: $languageSearch)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { isLanguageSheetDisplayed = false })
}
}
.navigationTitle("status.editor.language-select.navigation-title")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
}
private func languageSheetSection(languages: [Language]) -> some View {
ForEach(languages) { language in
HStack {
languageTextView(
isoCode: language.isoCode,
nativeName: language.nativeName,
name: language.localizedName
).tag(language.isoCode)
Spacer()
if language.isoCode == focusedSEVM.selectedLanguage {
Image(systemName: "checkmark")
}
}
.listRowBackground(theme.primaryBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
focusedSEVM.selectedLanguage = language.isoCode
focusedSEVM.hasExplicitlySelectedLanguage = true
isLanguageSheetDisplayed = false
}
}
}
private var customEmojisSheet: some View {
NavigationStack {
ScrollView {
ForEach(focusedSEVM.customEmojiContainer) { container in
VStack(alignment: .leading) {
Text(container.categoryName)
.font(.scaledFootnote)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 9) {
ForEach(container.emojis) { emoji in
LazyImage(url: emoji.url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.accessibilityLabel(emoji.shortcode.replacingOccurrences(of: "_", with: " "))
.accessibilityAddTraits(.isButton)
} else if state.isLoading {
Rectangle()
.fill(Color.gray)
.frame(width: 40, height: 40)
.accessibility(hidden: true)
.shimmering()
}
}
.onTapGesture {
focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ")
}
}
}
}
.padding(.horizontal)
.padding(.bottom)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { isCustomEmojisSheetDisplay = false })
}
}
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.navigationTitle("status.editor.emojis.navigation-title")
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium])
}
private var recentlyUsedLanguages: [Language] {
preferences.recentlyUsedLanguages.compactMap { isoCode in
Language.allAvailableLanguages.first { $0.isoCode == isoCode }
}
}
private var otherLanguages: [Language] {
Language.allAvailableLanguages.filter { !preferences.recentlyUsedLanguages.contains($0.isoCode) }
}
private func languageSearchResult(query: String) -> [Language] {
Language.allAvailableLanguages.filter { language in
guard !languageSearch.isEmpty else {
return true
}
return language.nativeName?.lowercased().hasPrefix(query.lowercased()) == true
|| language.localizedName?.lowercased().hasPrefix(query.lowercased()) == true
}
}
}

View File

@ -1,36 +0,0 @@
import SwiftUI
import UIKit
struct StatusEditorCameraPickerView: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.dismiss) var dismiss
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let picker: StatusEditorCameraPickerView
init(picker: StatusEditorCameraPickerView) {
self.picker = picker
}
func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
guard let selectedImage = info[.originalImage] as? UIImage else { return }
picker.selectedImage = selectedImage
picker.dismiss()
}
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
#if !os(visionOS)
imagePicker.sourceType = .camera
#endif
imagePicker.delegate = context.coordinator
return imagePicker
}
func updateUIViewController(_: UIImagePickerController, context _: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(picker: self)
}
}

View File

@ -1,8 +0,0 @@
import Foundation
import Models
struct StatusEditorCategorizedEmojiContainer: Identifiable, Equatable {
let id = UUID().uuidString
let categoryName: String
var emojis: [Emoji]
}

View File

@ -1,102 +0,0 @@
import AVFoundation
import Foundation
import UIKit
public actor StatusEditorCompressor {
public init() { }
enum CompressorError: Error {
case noData
}
public func compressImageFrom(url: URL) async -> Data? {
await withCheckedContinuation { continuation in
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
continuation.resume(returning: nil)
return
}
let maxPixelSize: Int = if Bundle.main.bundlePath.hasSuffix(".appex") {
1536
} else {
4096
}
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
] as [CFString: Any] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else {
continuation.resume(returning: nil)
return
}
let data = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
continuation.resume(returning: nil)
return
}
let isPNG: Bool = {
guard let utType = cgImage.utType else { return false }
return (utType as String) == UTType.png.identifier
}()
let destinationProperties = [
kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75,
] as CFDictionary
CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties)
CGImageDestinationFinalize(imageDestination)
continuation.resume(returning: data as Data)
}
}
public func compressImageForUpload(_ image: UIImage) async throws -> Data {
var image = image
if image.size.height > 5000 || image.size.width > 5000 {
image = image.resized(to: .init(width: image.size.width / 4,
height: image.size.height / 4))
}
guard var imageData = image.jpegData(compressionQuality: 0.8) else {
throw CompressorError.noData
}
let maxSize = 10 * 1024 * 1024
if imageData.count > maxSize {
while imageData.count > maxSize {
guard let compressedImage = UIImage(data: imageData),
let compressedData = compressedImage.jpegData(compressionQuality: 0.8)
else {
throw CompressorError.noData
}
imageData = compressedData
}
}
return imageData
}
func compressVideo(_ url: URL) async -> URL? {
await withCheckedContinuation { continuation in
let urlAsset = AVURLAsset(url: url, options: nil)
guard let exportSession = AVAssetExportSession(asset: urlAsset, presetName: AVAssetExportPreset1920x1080) else {
continuation.resume(returning: nil)
return
}
let outputURL = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(url.pathExtension)")
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
exportSession.exportAsynchronously { () in
continuation.resume(returning: outputURL)
}
}
}
}

View File

@ -1,14 +0,0 @@
import Foundation
import Models
import PhotosUI
import SwiftUI
import UIKit
struct StatusEditorMediaContainer: Identifiable {
let id: String
let image: UIImage?
let movieTransferable: MovieFileTranseferable?
let gifTransferable: GifFileTranseferable?
let mediaAttachment: MediaAttachment?
let error: Error?
}

View File

@ -1,178 +0,0 @@
import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
@MainActor
struct StatusEditorMediaEditView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
@Environment(UserPreferences.self) private var preferences
var viewModel: StatusEditorViewModel
let container: StatusEditorMediaContainer
@State private var imageDescription: String = ""
@FocusState private var isFieldFocused: Bool
@State private var isUpdating: Bool = false
@State private var didAppear: Bool = false
@State private var isGeneratingDescription: Bool = false
@State private var showTranslateButton: Bool = false
@State private var isTranslating: Bool = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("status.editor.media.image-description",
text: $imageDescription,
axis: .vertical)
.focused($isFieldFocused)
generateButton
translateButton
}
.listRowBackground(theme.primaryBackgroundColor)
Section {
if let url = container.mediaAttachment?.url {
AsyncImage(
url: url,
content: { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.cornerRadius(8)
.padding(8)
},
placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray)
.frame(height: 200)
.shimmering()
}
)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.onAppear {
if !didAppear {
imageDescription = container.mediaAttachment?.description ?? ""
isFieldFocused = true
didAppear = true
}
}
.navigationTitle("status.editor.media.edit-image")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if !imageDescription.isEmpty {
isUpdating = true
if currentInstance.isEditAltTextSupported, viewModel.mode.isEditing {
Task {
await viewModel.editDescription(container: container, description: imageDescription)
dismiss()
isUpdating = false
}
} else {
Task {
await viewModel.addDescription(container: container, description: imageDescription)
dismiss()
isUpdating = false
}
}
}
} label: {
if isUpdating {
ProgressView()
} else {
Text("action.done")
}
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel") {
dismiss()
}
}
}
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
}
}
@ViewBuilder
private var generateButton: some View {
if let url = container.mediaAttachment?.url, preferences.isOpenAIEnabled {
Button {
Task {
if let description = await generateDescription(url: url) {
imageDescription = description
let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
if lang != nil, lang != "en" {
withAnimation {
showTranslateButton = true
}
}
}
}
} label: {
if isGeneratingDescription {
ProgressView()
} else {
Text("status.editor.media.generate-description")
}
}
}
}
@ViewBuilder
private var translateButton: some View {
if showTranslateButton {
Button {
Task {
if let description = await translateDescription() {
imageDescription = description
withAnimation {
showTranslateButton = false
}
}
}
} label: {
if isTranslating {
ProgressView()
} else {
Text("status.action.translate")
}
}
}
}
private func generateDescription(url: URL) async -> String? {
isGeneratingDescription = true
let client = OpenAIClient()
let response = try? await client.request(.imageDescription(image: url))
isGeneratingDescription = false
return response?.trimmedText
}
private func translateDescription() async -> String? {
isTranslating = true
let userAPIKey = DeepLUserAPIHandler.readIfAllowed()
let userAPIFree = UserPreferences.shared.userDeeplAPIFree
let deeplClient = DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIFree)
let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier
guard let lang else { return nil }
let translation = try? await deeplClient.request(target: lang, text: imageDescription)
isTranslating = false
return translation?.content.asRawText
}
}

View File

@ -1,249 +0,0 @@
import AVKit
import DesignSystem
import Env
import MediaUI
import Models
import NukeUI
import SwiftUI
@MainActor
struct StatusEditorMediaView: View {
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
var viewModel: StatusEditorViewModel
@Binding var editingMediaContainer: StatusEditorMediaContainer?
@State private var isErrorDisplayed: Bool = false
@Namespace var mediaSpace
@State private var scrollID: String?
var body: some View {
ScrollView(.horizontal, showsIndicators: showsScrollIndicators) {
switch count {
case 1: mediaLayout
case 2: mediaLayout
case 3: mediaLayout
case 4: mediaLayout
default: mediaLayout
}
}
.scrollPosition(id: $scrollID, anchor: .trailing)
.padding(.horizontal, .layoutPadding)
.frame(height: count > 0 ? containerHeight : 0)
.animation(.spring(duration: 0.3), value: count)
.onChange(of: count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.5))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = containers.last?.id
}
}
}
}
}
private var count: Int { viewModel.mediaContainers.count }
private var containers: [StatusEditorMediaContainer] { viewModel.mediaContainers }
private let containerHeight: CGFloat = 300
private var containerWidth: CGFloat { containerHeight / 1.5 }
#if targetEnvironment(macCatalyst)
private var showsScrollIndicators: Bool { count > 1 }
private var scrollBottomPadding: CGFloat?
#else
private var showsScrollIndicators: Bool = false
private var scrollBottomPadding: CGFloat? = 0
#endif
init(viewModel: StatusEditorViewModel, editingMediaContainer: Binding<StatusEditorMediaContainer?>) {
self.viewModel = viewModel
_editingMediaContainer = editingMediaContainer
}
private func pixel(at index: Int) -> some View {
Rectangle().frame(width: 0, height: 0)
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
}
private var mediaLayout: some View {
HStack(alignment: .center, spacing: count > 1 ? 8 : 0) {
if count > 0 {
if count == 1 {
makeMediaItem(at: 0)
.containerRelativeFrame(.horizontal, alignment: .leading)
} else {
makeMediaItem(at: 0)
}
} else { pixel(at: 0) }
if count > 1 { makeMediaItem(at: 1) } else { pixel(at: 1) }
if count > 2 { makeMediaItem(at: 2) } else { pixel(at: 2) }
if count > 3 { makeMediaItem(at: 3) } else { pixel(at: 3) }
}
.padding(.bottom, scrollBottomPadding)
.scrollTargetLayout()
}
private func makeMediaItem(at index: Int) -> some View {
let container = viewModel.mediaContainers[index]
return Menu {
makeImageMenu(container: container)
} label: {
RoundedRectangle(cornerRadius: 8).fill(.clear)
.overlay {
if let attachement = container.mediaAttachment {
makeRemoteMediaView(mediaAttachement: attachement)
} else if container.image != nil {
makeLocalImageView(container: container)
} else if let error = container.error as? ServerError {
makeErrorView(error: error)
} else {
placeholderView
}
}
}
.overlay(alignment: .bottomTrailing) {
makeAltMarker(container: container)
}
.overlay(alignment: .topTrailing) {
makeDiscardMarker(container: container)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minWidth: count == 1 ? nil : containerWidth, maxWidth: 600)
.id(container.id)
.matchedGeometryEffect(id: container.id, in: mediaSpace, anchor: .leading)
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
}
private func makeLocalImageView(container: StatusEditorMediaContainer) -> some View {
ZStack(alignment: .center) {
Image(uiImage: container.image!)
.resizable()
.blur(radius: container.mediaAttachment == nil ? 20 : 0)
.scaledToFill()
.cornerRadius(8)
if container.error != nil {
Text("status.editor.error.upload")
} else if container.mediaAttachment == nil {
ProgressView()
}
}
}
private func makeRemoteMediaView(mediaAttachement: MediaAttachment) -> some View {
ZStack(alignment: .center) {
switch mediaAttachement.supportedType {
case .gifv, .video, .audio:
if let url = mediaAttachement.url {
MediaUIAttachmentVideoView(viewModel: .init(url: url, forceAutoPlay: true))
} else {
placeholderView
}
case .image:
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizable()
.scaledToFill()
} else {
placeholderView
}
}
}
case .none:
EmptyView()
}
}
.cornerRadius(8)
}
@ViewBuilder
private func makeImageMenu(container: StatusEditorMediaContainer) -> some View {
if container.mediaAttachment?.url != nil {
if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing {
Button {
editingMediaContainer = container
} label: {
Label(container.mediaAttachment?.description?.isEmpty == false ?
"status.editor.description.edit" : "status.editor.description.add",
systemImage: "pencil.line")
}
}
} else if container.error != nil {
Button {
isErrorDisplayed = true
} label: {
Label("action.view.error", systemImage: "exclamationmark.triangle")
}
}
Button(role: .destructive) {
deleteAction(container: container)
} label: {
Label("action.delete", systemImage: "trash")
}
}
private func makeErrorView(error: ServerError) -> some View {
ZStack {
placeholderView
Text("status.editor.error.upload")
}
.alert("alert.error", isPresented: $isErrorDisplayed) {
Button("Ok", action: {})
} message: {
Text(error.error ?? "")
}
}
private func makeAltMarker(container: StatusEditorMediaContainer) -> some View {
Button {
editingMediaContainer = container
} label: {
Text("status.image.alt-text.abbreviation")
.font(.caption2)
}
.padding(8)
.background(.thinMaterial)
.cornerRadius(8)
.padding(4)
}
private func makeDiscardMarker(container: StatusEditorMediaContainer) -> some View {
Button(role: .destructive) {
deleteAction(container: container)
} label: {
Image(systemName: "xmark")
.font(.caption2)
.foregroundStyle(.tint)
.padding(8)
.background(Circle().fill(.thinMaterial))
}
.padding(4)
}
private func deleteAction(container: StatusEditorMediaContainer) {
viewModel.mediaPickers.removeAll(where: {
if let id = $0.itemIdentifier {
return id == container.id
}
return false
})
viewModel.mediaContainers.removeAll {
$0.id == container.id
}
}
private var placeholderView: some View {
ZStack(alignment: .center) {
Rectangle()
.foregroundColor(theme.secondaryBackgroundColor)
.accessibilityHidden(true)
ProgressView()
}
.cornerRadius(8)
}
}

View File

@ -1,122 +0,0 @@
import DesignSystem
import Env
import SwiftUI
@MainActor
struct StatusEditorPollView: View {
enum FocusField: Hashable {
case option(Int)
}
@FocusState var focused: FocusField?
@State private var currentFocusIndex: Int = 0
@Environment(Theme.self) private var theme
@Environment(CurrentInstance.self) private var currentInstance
var viewModel: StatusEditorViewModel
@Binding var showPoll: Bool
var body: some View {
@Bindable var viewModel = viewModel
let count = viewModel.pollOptions.count
VStack {
ForEach(0 ..< count, id: \.self) { index in
VStack {
HStack(spacing: 16) {
TextField("status.poll.option-n \(index + 1)", text: $viewModel.pollOptions[index])
.textFieldStyle(.roundedBorder)
.focused($focused, equals: .option(index))
.onTapGesture {
if canAddMoreAt(index) {
currentFocusIndex = index
}
}
.onSubmit {
if canAddMoreAt(index) {
addChoice(at: index)
}
}
if canAddMoreAt(index) {
Button {
addChoice(at: index)
} label: {
Image(systemName: "plus.circle.fill")
}
} else {
Button {
removeChoice(at: index)
} label: {
Image(systemName: "minus.circle.fill")
}
}
}
.padding(.horizontal)
.padding(.top)
}
}
.onAppear {
focused = .option(0)
}
HStack {
Picker("status.poll.frequency", selection: $viewModel.pollVotingFrequency) {
ForEach(PollVotingFrequency.allCases, id: \.rawValue) {
Text($0.displayString)
.tag($0)
}
}
.layoutPriority(1.0)
Spacer()
Picker("status.poll.duration", selection: $viewModel.pollDuration) {
ForEach(Duration.pollDurations(), id: \.rawValue) {
Text($0.description)
.tag($0)
}
}
}
.padding(.horizontal)
}
.background(
RoundedRectangle(cornerRadius: 6.0)
.stroke(theme.secondaryBackgroundColor.opacity(0.6), lineWidth: 1)
.background(theme.primaryBackgroundColor.opacity(0.3))
)
}
private func addChoice(at index: Int) {
viewModel.pollOptions.append("")
currentFocusIndex = index + 1
moveFocus()
}
private func removeChoice(at index: Int) {
viewModel.pollOptions.remove(at: index)
if viewModel.pollOptions.count == 1 {
viewModel.resetPollDefaults()
withAnimation {
showPoll = false
}
}
}
private func moveFocus() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
focused = .option(currentFocusIndex)
}
}
private func canAddMoreAt(_ index: Int) -> Bool {
let count = viewModel.pollOptions.count
let maxEntries: Int = currentInstance.instance?.configuration?.polls.maxOptions ?? 4
return index == count - 1 && count < maxEntries
}
}

View File

@ -1,204 +0,0 @@
@preconcurrency import AVFoundation
import Foundation
import PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
@MainActor
enum StatusEditorUTTypeSupported: String, CaseIterable {
case url = "public.url"
case text = "public.text"
case plaintext = "public.plain-text"
case image = "public.image"
case jpeg = "public.jpeg"
case png = "public.png"
case tiff = "public.tiff"
case video = "public.video"
case movie = "public.movie"
case mp4 = "public.mpeg-4"
case gif = "public.gif"
case gif2 = "com.compuserve.gif"
case quickTimeMovie = "com.apple.quicktime-movie"
case adobeRawImage = "com.adobe.raw-image"
case uiimage = "com.apple.uikit.image"
// Have to implement this manually here due to compiler not implicitly
// inserting `nonisolated`, which leads to a warning:
//
// Main actor-isolated static property 'allCases' cannot be used to
// satisfy nonisolated protocol requirement
//
public nonisolated static var allCases: [StatusEditorUTTypeSupported] {
[.url, .text, .plaintext, .image, .jpeg, .png, .tiff, .video,
.movie, .mp4, .gif, .gif2, .quickTimeMovie, .uiimage, .adobeRawImage]
}
static func types() -> [UTType] {
[.url, .text, .plainText, .image, .jpeg, .png, .tiff, .video, .mpeg4Movie, .gif, .movie, .quickTimeMovie]
}
var isVideo: Bool {
switch self {
case .video, .movie, .mp4, .quickTimeMovie:
true
default:
false
}
}
var isGif: Bool {
switch self {
case .gif, .gif2:
true
default:
false
}
}
func loadItemContent(item: NSItemProvider) async throws -> Any? {
// Many warnings here about non-sendable type `[AnyHashable: Any]?` crossing
// actor boundaries. Many Radars have been filed.
if isVideo, let transferable = await getVideoTransferable(item: item) {
return transferable
} else if isGif, let transferable = await getGifTransferable(item: item) {
return transferable
}
let compressor = StatusEditorCompressor()
let result = try await item.loadItem(forTypeIdentifier: rawValue)
if self == .jpeg || self == .png || self == .tiff || self == .image || self == .uiimage || self == .adobeRawImage {
if let image = result as? UIImage,
let compressedData = try? await compressor.compressImageForUpload(image),
let compressedImage = UIImage(data: compressedData)
{
return compressedImage
} else if let imageURL = result as? URL,
let compressedData = await compressor.compressImageFrom(url: imageURL),
let image = UIImage(data: compressedData)
{
return image
} else if let data = result as? Data,
let image = UIImage(data: data)
{
return image
}
}
if let transferable = await getImageTansferable(item: item) {
return transferable
}
if let url = result as? URL {
return url.absoluteString
} else if let text = result as? String {
return text
} else if let image = result as? UIImage {
return image
} else {
return nil
}
}
private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: MovieFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
private func getGifTransferable(item: NSItemProvider) async -> GifFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: GifFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
private func getImageTansferable(item: NSItemProvider) async -> ImageFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: ImageFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
}
struct MovieFileTranseferable: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url)
} importing: { received in
Self(url: localURLFor(received: received))
}
}
}
public struct ImageFileTranseferable: Transferable, Sendable {
public let url: URL
public static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url)
} importing: { received in
Self(url: localURLFor(received: received))
}
}
}
struct GifFileTranseferable: Transferable {
let url: URL
var data: Data? {
try? Data(contentsOf: url)
}
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .gif) { gif in
SentTransferredFile(gif.url)
} importing: { received in
Self(url: localURLFor(received: received))
}
}
}
private func localURLFor(received: ReceivedTransferredFile) -> URL {
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try? FileManager.default.copyItem(at: received.file, to: copy)
return copy
}
public extension URL {
func mimeType() -> String {
if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
mimeType
} else {
"application/octet-stream"
}
}
}
extension UIImage {
func resized(to size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
}

View File

@ -0,0 +1,212 @@
@preconcurrency import AVFoundation
import Foundation
import PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
extension StatusEditor {
@MainActor
enum UTTypeSupported: String, CaseIterable {
case url = "public.url"
case text = "public.text"
case plaintext = "public.plain-text"
case image = "public.image"
case jpeg = "public.jpeg"
case png = "public.png"
case tiff = "public.tiff"
case video = "public.video"
case movie = "public.movie"
case mp4 = "public.mpeg-4"
case gif = "public.gif"
case gif2 = "com.compuserve.gif"
case quickTimeMovie = "com.apple.quicktime-movie"
case adobeRawImage = "com.adobe.raw-image"
case uiimage = "com.apple.uikit.image"
// Have to implement this manually here due to compiler not implicitly
// inserting `nonisolated`, which leads to a warning:
//
// Main actor-isolated static property 'allCases' cannot be used to
// satisfy nonisolated protocol requirement
//
public nonisolated static var allCases: [UTTypeSupported] {
[.url, .text, .plaintext, .image, .jpeg, .png, .tiff, .video,
.movie, .mp4, .gif, .gif2, .quickTimeMovie, .uiimage, .adobeRawImage]
}
static func types() -> [UTType] {
[.url, .text, .plainText, .image, .jpeg, .png, .tiff, .video, .mpeg4Movie, .gif, .movie, .quickTimeMovie]
}
var isVideo: Bool {
switch self {
case .video, .movie, .mp4, .quickTimeMovie:
true
default:
false
}
}
var isGif: Bool {
switch self {
case .gif, .gif2:
true
default:
false
}
}
func loadItemContent(item: NSItemProvider) async throws -> Any? {
// Many warnings here about non-sendable type `[AnyHashable: Any]?` crossing
// actor boundaries. Many Radars have been filed.
if isVideo, let transferable = await getVideoTransferable(item: item) {
return transferable
} else if isGif, let transferable = await getGifTransferable(item: item) {
return transferable
}
let compressor = Compressor()
let result = try await item.loadItem(forTypeIdentifier: rawValue)
if self == .jpeg || self == .png || self == .tiff || self == .image || self == .uiimage || self == .adobeRawImage {
if let image = result as? UIImage,
let compressedData = try? await compressor.compressImageForUpload(image),
let compressedImage = UIImage(data: compressedData)
{
return compressedImage
} else if let imageURL = result as? URL,
let compressedData = await compressor.compressImageFrom(url: imageURL),
let image = UIImage(data: compressedData)
{
return image
} else if let data = result as? Data,
let image = UIImage(data: data)
{
return image
}
}
if let transferable = await getImageTansferable(item: item) {
return transferable
}
if let url = result as? URL {
return url.absoluteString
} else if let text = result as? String {
return text
} else if let image = result as? UIImage {
return image
} else {
return nil
}
}
private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: MovieFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
private func getGifTransferable(item: NSItemProvider) async -> GifFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: GifFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
private func getImageTansferable(item: NSItemProvider) async -> ImageFileTranseferable? {
await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: ImageFileTranseferable.self) { result in
switch result {
case let .success(success):
continuation.resume(with: .success(success))
case .failure:
continuation.resume(with: .success(nil))
}
}
}
}
}
}
extension StatusEditor {
struct MovieFileTranseferable: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url)
} importing: { received in
Self(url: received.localURL)
}
}
}
struct GifFileTranseferable: Transferable {
let url: URL
var data: Data? {
try? Data(contentsOf: url)
}
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .gif) { gif in
SentTransferredFile(gif.url)
} importing: { received in
Self(url: received.localURL)
}
}
}
}
public extension StatusEditor {
struct ImageFileTranseferable: Transferable, Sendable {
public let url: URL
public static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url)
} importing: { received in
Self(url: received.localURL)
}
}
}
}
public extension ReceivedTransferredFile {
var localURL: URL {
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(self.file.pathExtension)")
try? FileManager.default.copyItem(at: self.file, to: copy)
return copy
}
}
public extension URL {
func mimeType() -> String {
if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
mimeType
} else {
"application/octet-stream"
}
}
}
extension UIImage {
func resized(to size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
}

View File

@ -3,50 +3,53 @@ import Models
import SwiftData
import SwiftUI
struct DraftsListView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
extension StatusEditor {
struct DraftsListView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
@Environment(Theme.self) private var theme
@Query(sort: \Draft.creationDate, order: .reverse) var drafts: [Draft]
@Query(sort: \Draft.creationDate, order: .reverse) var drafts: [Draft]
@Binding var selectedDraft: Draft?
@Binding var selectedDraft: Draft?
var body: some View {
NavigationStack {
List {
ForEach(drafts) { draft in
Button {
selectedDraft = draft
dismiss()
} label: {
VStack(alignment: .leading, spacing: 8) {
Text(draft.content)
.font(.body)
.lineLimit(3)
.foregroundStyle(theme.labelColor)
Text(draft.creationDate, style: .relative)
.font(.footnote)
.foregroundStyle(.gray)
var body: some View {
NavigationStack {
List {
ForEach(drafts) { draft in
Button {
selectedDraft = draft
dismiss()
} label: {
VStack(alignment: .leading, spacing: 8) {
Text(draft.content)
.font(.body)
.lineLimit(3)
.foregroundStyle(theme.labelColor)
Text(draft.creationDate, style: .relative)
.font(.footnote)
.foregroundStyle(.gray)
}
}.listRowBackground(theme.primaryBackgroundColor)
}
.onDelete { indexes in
if let index = indexes.first {
context.delete(drafts[index])
}
}.listRowBackground(theme.primaryBackgroundColor)
}
.onDelete { indexes in
if let index = indexes.first {
context.delete(drafts[index])
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.navigationTitle("status.editor.drafts.navigation-title")
.navigationBarTitleDisplayMode(.inline)
}
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.navigationTitle("status.editor.drafts.navigation-title")
.navigationBarTitleDisplayMode(.inline)
}
}
}

View File

@ -0,0 +1,8 @@
import SwiftUI
extension StatusEditor {
enum EditorFocusState: Hashable {
case main, followUp(index: UUID)
}
}

View File

@ -0,0 +1,183 @@
import AppAccount
import DesignSystem
import Env
import Models
import Network
import SwiftUI
extension StatusEditor {
@MainActor
struct EditorView: View {
@Bindable var viewModel: ViewModel
@Binding var followUpSEVMs: [ViewModel]
@Binding var editingMediaContainer: MediaContainer?
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
@FocusState<EditorFocusState?>.Binding var editorFocusState: EditorFocusState?
let assignedFocusState: EditorFocusState
let isMain: Bool
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(CurrentAccount.self) private var currentAccount
@Environment(CurrentInstance.self) private var currentInstance
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(Client.self) private var client
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some View {
HStack(spacing: 0) {
if !isMain {
Rectangle()
.fill(theme.tintColor)
.frame(width: 2)
.accessibilityHidden(true)
.padding(.leading, .layoutPadding)
}
VStack(spacing: 0) {
spoilerTextView
VStack(spacing: 0) {
accountHeaderView
textInput
characterCountView
MediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer)
embeddedStatus
pollView
}
.padding(.vertical)
Divider()
}
.opacity(editorFocusState == assignedFocusState ? 1 : 0.6)
}
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
#endif
.focused($editorFocusState, equals: assignedFocusState)
.onAppear { setupViewModel() }
}
@ViewBuilder
private var spoilerTextView: some View {
if viewModel.spoilerOn {
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
.focused($isSpoilerTextFocused, equals: viewModel.id)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
.background(theme.tintColor.opacity(0.20))
}
}
@ViewBuilder
private var accountHeaderView: some View {
if let account = currentAccount.account, !viewModel.mode.isEditing {
HStack {
if viewModel.mode.isInShareExtension {
AppAccountsSelectorView(routerPath: RouterPath(),
accountCreationEnabled: false,
avatarConfig: .status)
} else {
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
.environment(theme)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
PrivacyMenu(visibility: $viewModel.visibility, tint: isMain ? theme.tintColor : .secondary)
.disabled(!isMain)
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
Spacer()
if case let .followUp(id) = assignedFocusState {
Button {
followUpSEVMs.removeAll { $0.id == id }
} label: {
HStack {
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
}
}
}
}
.padding(.horizontal, .layoutPadding)
}
}
private var textInput: some View {
TextView(
$viewModel.statusText,
getTextView: { textView in viewModel.textView = textView }
)
.placeholder(String(localized: isMain ? "status.editor.text.placeholder" : "status.editor.follow-up.text.placeholder"))
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
}
@ViewBuilder
private var embeddedStatus: some View {
if viewModel.replyToStatus != nil { Divider().padding(.top, 20) }
if let status = viewModel.embeddedStatus ?? viewModel.replyToStatus {
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
.padding(.horizontal, .layoutPadding)
.disabled(true)
}
}
@ViewBuilder
private var pollView: some View {
if viewModel.showPoll {
PollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
.padding(.horizontal)
}
}
@ViewBuilder
private var characterCountView: some View {
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength
HStack {
Spacer()
Text("\(value)")
.foregroundColor(value < 0 ? .red : .secondary)
.font(.scaledCallout)
.accessibilityLabel("accessibility.editor.button.characters-remaining")
.accessibilityValue("\(value)")
.accessibilityRemoveTraits(.isStaticText)
.accessibilityAddTraits(.updatesFrequently)
.accessibilityRespondsToUserInteraction(false)
.padding(.trailing, 8)
.padding(.bottom, 8)
}
}
private func setupViewModel() {
viewModel.client = client
viewModel.currentAccount = currentAccount.account
viewModel.theme = theme
viewModel.preferences = preferences
viewModel.prepareStatusText()
if !client.isAuth {
#if targetEnvironment(macCatalyst)
dismissWindow()
#else
dismiss()
#endif
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
}
Task { await viewModel.fetchCustomEmojis() }
}
}
}

View File

@ -0,0 +1,150 @@
import AppAccount
import DesignSystem
import EmojiText
import Env
import Models
import Network
import NukeUI
import PhotosUI
import StoreKit
import SwiftUI
import UIKit
extension StatusEditor {
@MainActor
public struct MainView: View {
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(CurrentAccount.self) private var currentAccount
@Environment(Theme.self) private var theme
@State private var presentationDetent: PresentationDetent = .large
@State private var mainSEVM: ViewModel
@State private var followUpSEVMs: [ViewModel] = []
@FocusState private var isSpoilerTextFocused: UUID? // connect CoreEditor and StatusEditorAccessoryView
@State private var editingMediaContainer: MediaContainer?
@State private var scrollID: UUID?
@FocusState private var editorFocusState: EditorFocusState?
private var focusedSEVM: ViewModel {
if case let .followUp(id) = editorFocusState,
let sevm = followUpSEVMs.first(where: { $0.id == id })
{ return sevm }
return mainSEVM
}
public init(mode: ViewModel.Mode) {
_mainSEVM = State(initialValue: ViewModel(mode: mode))
}
public var body: some View {
@Bindable var focusedSEVM = focusedSEVM
NavigationStack {
ScrollView {
VStackLayout(spacing: 0) {
EditorView(
viewModel: mainSEVM,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .main,
isMain: true
)
.id(mainSEVM.id)
ForEach(followUpSEVMs) { sevm in
@Bindable var sevm: ViewModel = sevm
EditorView(
viewModel: sevm,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .followUp(index: sevm.id),
isMain: false
)
.id(sevm.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollID, anchor: .top)
.animation(.bouncy(duration: 0.3), value: editorFocusState)
.animation(.bouncy(duration: 0.3), value: followUpSEVMs)
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
#endif
.safeAreaInset(edge: .bottom) {
AutoCompleteView(viewModel: focusedSEVM)
}
#if os(visionOS)
.ornament(attachmentAnchor: .scene(.bottom)) {
AccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
}
#else
.safeAreaInset(edge: .bottom) {
if presentationDetent == .large || presentationDetent == .medium {
AccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
}
}
#endif
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
.navigationTitle(focusedSEVM.mode.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItems(mainSEVM: mainSEVM, followUpSEVMs: followUpSEVMs) }
.toolbarBackground(.visible, for: .navigationBar)
.alert(
"status.error.posting.title",
isPresented: $focusedSEVM.showPostingErrorAlert,
actions: {
Button("OK") {}
}, message: {
Text(mainSEVM.postingError ?? "")
}
)
.interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning)
.onChange(of: appAccounts.currentClient) { _, newValue in
if mainSEVM.mode.isInShareExtension {
currentAccount.setClient(client: newValue)
mainSEVM.client = newValue
for post in followUpSEVMs {
post.client = newValue
}
}
}
.onDrop(of: StatusEditor.UTTypeSupported.types(), delegate: focusedSEVM)
.onChange(of: currentAccount.account?.id) {
mainSEVM.currentAccount = currentAccount.account
for p in followUpSEVMs {
p.currentAccount = mainSEVM.currentAccount
}
}
.onChange(of: mainSEVM.visibility) {
for p in followUpSEVMs {
p.visibility = mainSEVM.visibility
}
}
.onChange(of: followUpSEVMs.count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.1))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = followUpSEVMs.last?.id
}
}
}
}
}
.sheet(item: $editingMediaContainer) { container in
StatusEditor.MediaEditView(viewModel: focusedSEVM, container: container)
}
.presentationDetents([.large, .medium, .height(50)], selection: $presentationDetent)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
}
}

View File

@ -0,0 +1 @@
public enum StatusEditor { }

View File

@ -0,0 +1,34 @@
import Models
import SwiftUI
extension StatusEditor {
struct PrivacyMenu: View {
@Binding var visibility: Models.Visibility
let tint: Color
var body: some View {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { vis in
Button { visibility = vis } label: {
Label(vis.title, systemImage: vis.iconName)
}
}
} label: {
HStack {
Label(visibility.title, systemImage: visibility.iconName)
.accessibilityLabel("accessibility.editor.privacy.label")
.accessibilityValue(visibility.title)
.accessibilityHint("accessibility.editor.privacy.hint")
Image(systemName: "chevron.down")
}
.font(.scaledFootnote)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(tint, lineWidth: 1)
)
}
}
}
}

View File

@ -1,180 +0,0 @@
import AppAccount
import DesignSystem
import Env
import Models
import Network
import SwiftUI
@MainActor
struct StatusEditorCoreView: View {
@Bindable var viewModel: StatusEditorViewModel
@Binding var followUpSEVMs: [StatusEditorViewModel]
@Binding var editingMediaContainer: StatusEditorMediaContainer?
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
@FocusState<StatusEditorFocusState?>.Binding var editorFocusState: StatusEditorFocusState?
let assignedFocusState: StatusEditorFocusState
let isMain: Bool
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(CurrentAccount.self) private var currentAccount
@Environment(CurrentInstance.self) private var currentInstance
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(Client.self) private var client
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some View {
HStack(spacing: 0) {
if !isMain {
Rectangle()
.fill(theme.tintColor)
.frame(width: 2)
.accessibilityHidden(true)
.padding(.leading, .layoutPadding)
}
VStack(spacing: 0) {
spoilerTextView
VStack(spacing: 0) {
accountHeaderView
textInput
characterCountView
StatusEditorMediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer)
embeddedStatus
pollView
}
.padding(.vertical)
Divider()
}
.opacity(editorFocusState == assignedFocusState ? 1 : 0.6)
}
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
#endif
.focused($editorFocusState, equals: assignedFocusState)
.onAppear { setupViewModel() }
}
@ViewBuilder
private var spoilerTextView: some View {
if viewModel.spoilerOn {
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
.focused($isSpoilerTextFocused, equals: viewModel.id)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
.background(theme.tintColor.opacity(0.20))
}
}
@ViewBuilder
private var accountHeaderView: some View {
if let account = currentAccount.account, !viewModel.mode.isEditing {
HStack {
if viewModel.mode.isInShareExtension {
AppAccountsSelectorView(routerPath: RouterPath(),
accountCreationEnabled: false,
avatarConfig: .status)
} else {
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
.environment(theme)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
StatusEditorPrivacyMenu(visibility: $viewModel.visibility, tint: isMain ? theme.tintColor : .secondary)
.disabled(!isMain)
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
Spacer()
if case let .followUp(id) = assignedFocusState {
Button {
followUpSEVMs.removeAll { $0.id == id }
} label: {
HStack {
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
}
}
}
}
.padding(.horizontal, .layoutPadding)
}
}
private var textInput: some View {
TextView(
$viewModel.statusText,
getTextView: { textView in viewModel.textView = textView }
)
.placeholder(String(localized: isMain ? "status.editor.text.placeholder" : "status.editor.follow-up.text.placeholder"))
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
.padding(.horizontal, .layoutPadding)
.padding(.vertical)
}
@ViewBuilder
private var embeddedStatus: some View {
if viewModel.replyToStatus != nil { Divider().padding(.top, 20) }
if let status = viewModel.embeddedStatus ?? viewModel.replyToStatus {
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
.padding(.horizontal, .layoutPadding)
.disabled(true)
}
}
@ViewBuilder
private var pollView: some View {
if viewModel.showPoll {
StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
.padding(.horizontal)
}
}
@ViewBuilder
private var characterCountView: some View {
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength
HStack {
Spacer()
Text("\(value)")
.foregroundColor(value < 0 ? .red : .secondary)
.font(.scaledCallout)
.accessibilityLabel("accessibility.editor.button.characters-remaining")
.accessibilityValue("\(value)")
.accessibilityRemoveTraits(.isStaticText)
.accessibilityAddTraits(.updatesFrequently)
.accessibilityRespondsToUserInteraction(false)
.padding(.trailing, 8)
.padding(.bottom, 8)
}
}
private func setupViewModel() {
viewModel.client = client
viewModel.currentAccount = currentAccount.account
viewModel.theme = theme
viewModel.preferences = preferences
viewModel.prepareStatusText()
if !client.isAuth {
#if targetEnvironment(macCatalyst)
dismissWindow()
#else
dismiss()
#endif
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
}
Task { await viewModel.fetchCustomEmojis() }
}
}

View File

@ -1,5 +0,0 @@
import SwiftUI
enum StatusEditorFocusState: Hashable {
case main, followUp(index: UUID)
}

View File

@ -1,31 +0,0 @@
import Models
import SwiftUI
struct StatusEditorPrivacyMenu: View {
@Binding var visibility: Models.Visibility
let tint: Color
var body: some View {
Menu {
ForEach(Models.Visibility.allCases, id: \.self) { vis in
Button { visibility = vis } label: {
Label(vis.title, systemImage: vis.iconName)
}
}
} label: {
HStack {
Label(visibility.title, systemImage: visibility.iconName)
.accessibilityLabel("accessibility.editor.privacy.label")
.accessibilityValue(visibility.title)
.accessibilityHint("accessibility.editor.privacy.hint")
Image(systemName: "chevron.down")
}
.font(.scaledFootnote)
.padding(4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(tint, lineWidth: 1)
)
}
}
}

View File

@ -1,139 +0,0 @@
import Env
import Models
import StoreKit
import SwiftUI
@MainActor
struct StatusEditorToolbarItems: ToolbarContent {
@State private var isLanguageConfirmPresented = false
@State private var isDismissAlertPresented: Bool = false
let mainSEVM: StatusEditorViewModel
let followUpSEVMs: [StatusEditorViewModel]
@Environment(\.modelContext) private var context
@Environment(UserPreferences.self) private var preferences
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
mainSEVM.evaluateLanguages()
if preferences.autoDetectPostLanguage, let _ = mainSEVM.languageConfirmationDialogLanguages {
isLanguageConfirmPresented = true
} else {
await postAllStatus()
}
}
} label: {
if mainSEVM.isPosting {
ProgressView()
} else {
Text("status.action.post").bold()
}
}
.disabled(!mainSEVM.canPost)
.keyboardShortcut(.return, modifiers: .command)
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
languageConfirmationDialog
})
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
if mainSEVM.shouldDisplayDismissWarning {
isDismissAlertPresented = true
} else {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
} label: {
Text("action.cancel")
}
.keyboardShortcut(.cancelAction)
.confirmationDialog(
"",
isPresented: $isDismissAlertPresented,
actions: {
Button("status.draft.delete", role: .destructive) {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("status.draft.save") {
context.insert(Draft(content: mainSEVM.statusText.string))
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("action.cancel", role: .cancel) {}
}
)
}
}
@discardableResult
private func postStatus(with model: StatusEditorViewModel, isMainPost: Bool) async -> Status? {
let status = await model.postStatus()
if status != nil, isMainPost {
close()
SoundEffectManager.shared.playSound(.tootSent)
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
#if !targetEnvironment(macCatalyst)
if !mainSEVM.mode.isInShareExtension, !preferences.requestedReview {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
preferences.requestedReview = true
}
#endif
}
return status
}
private func postAllStatus() async {
guard var latestPost = await postStatus(with: mainSEVM, isMainPost: true) else { return }
for p in followUpSEVMs {
p.mode = .replyTo(status: latestPost)
guard let post = await postStatus(with: p, isMainPost: false) else {
break
}
latestPost = post
}
}
#if targetEnvironment(macCatalyst)
private func close() { dismissWindow() }
#else
private func close() { dismiss() }
#endif
@ViewBuilder
private var languageConfirmationDialog: some View {
if let (detected: detected, selected: selected) = mainSEVM.languageConfirmationDialogLanguages,
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
let selectedLong = Locale.current.localizedString(forLanguageCode: selected)
{
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
mainSEVM.selectedLanguage = detected
Task { await postAllStatus() }
}
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
mainSEVM.selectedLanguage = selected
Task { await postAllStatus() }
}
Button("action.cancel", role: .cancel) {
mainSEVM.languageConfirmationDialogLanguages = nil
}
} else {
EmptyView()
}
}
}

View File

@ -1,147 +0,0 @@
import AppAccount
import DesignSystem
import EmojiText
import Env
import Models
import Network
import NukeUI
import PhotosUI
import StoreKit
import SwiftUI
import UIKit
@MainActor
public struct StatusEditorView: View {
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(CurrentAccount.self) private var currentAccount
@Environment(Theme.self) private var theme
@State private var presentationDetent: PresentationDetent = .large
@State private var mainSEVM: StatusEditorViewModel
@State private var followUpSEVMs: [StatusEditorViewModel] = []
@FocusState private var isSpoilerTextFocused: UUID? // connect CoreEditor and StatusEditorAccessoryView
@State private var editingMediaContainer: StatusEditorMediaContainer?
@State private var scrollID: UUID?
@FocusState private var editorFocusState: StatusEditorFocusState?
private var focusedSEVM: StatusEditorViewModel {
if case let .followUp(id) = editorFocusState,
let sevm = followUpSEVMs.first(where: { $0.id == id })
{ return sevm }
return mainSEVM
}
public init(mode: StatusEditorViewModel.Mode) {
_mainSEVM = State(initialValue: StatusEditorViewModel(mode: mode))
}
public var body: some View {
@Bindable var focusedSEVM = focusedSEVM
NavigationStack {
ScrollView {
VStackLayout(spacing: 0) {
StatusEditorCoreView(
viewModel: mainSEVM,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .main,
isMain: true
)
.id(mainSEVM.id)
ForEach(followUpSEVMs) { sevm in
@Bindable var sevm: StatusEditorViewModel = sevm
StatusEditorCoreView(
viewModel: sevm,
followUpSEVMs: $followUpSEVMs,
editingMediaContainer: $editingMediaContainer,
isSpoilerTextFocused: $isSpoilerTextFocused,
editorFocusState: $editorFocusState,
assignedFocusState: .followUp(index: sevm.id),
isMain: false
)
.id(sevm.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollID, anchor: .top)
.animation(.bouncy(duration: 0.3), value: editorFocusState)
.animation(.bouncy(duration: 0.3), value: followUpSEVMs)
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
#endif
.safeAreaInset(edge: .bottom) {
StatusEditorAutoCompleteView(viewModel: focusedSEVM)
}
#if os(visionOS)
.ornament(attachmentAnchor: .scene(.bottom)) {
StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
}
#else
.safeAreaInset(edge: .bottom) {
if presentationDetent == .large || presentationDetent == .medium {
StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
}
}
#endif
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
.navigationTitle(focusedSEVM.mode.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { StatusEditorToolbarItems(mainSEVM: mainSEVM, followUpSEVMs: followUpSEVMs) }
.toolbarBackground(.visible, for: .navigationBar)
.alert(
"status.error.posting.title",
isPresented: $focusedSEVM.showPostingErrorAlert,
actions: {
Button("OK") {}
}, message: {
Text(mainSEVM.postingError ?? "")
}
)
.interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning)
.onChange(of: appAccounts.currentClient) { _, newValue in
if mainSEVM.mode.isInShareExtension {
currentAccount.setClient(client: newValue)
mainSEVM.client = newValue
for post in followUpSEVMs {
post.client = newValue
}
}
}
.onDrop(of: StatusEditorUTTypeSupported.types(), delegate: focusedSEVM)
.onChange(of: currentAccount.account?.id) {
mainSEVM.currentAccount = currentAccount.account
for p in followUpSEVMs {
p.currentAccount = mainSEVM.currentAccount
}
}
.onChange(of: mainSEVM.visibility) {
for p in followUpSEVMs {
p.visibility = mainSEVM.visibility
}
}
.onChange(of: followUpSEVMs.count) { oldValue, newValue in
if oldValue < newValue {
Task {
try? await Task.sleep(for: .seconds(0.1))
withAnimation(.bouncy(duration: 0.5)) {
scrollID = followUpSEVMs.last?.id
}
}
}
}
}
.sheet(item: $editingMediaContainer) { container in
StatusEditorMediaEditView(viewModel: focusedSEVM, container: container)
}
.presentationDetents([.large, .medium, .height(50)], selection: $presentationDetent)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
}
}

View File

@ -1,894 +0,0 @@
import Combine
import DesignSystem
import Env
import Models
import NaturalLanguage
import Network
import PhotosUI
import SwiftUI
@MainActor
@Observable public class StatusEditorViewModel: 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 = ""
var isPosting: Bool = false
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: [StatusEditorMediaContainer] = []
var replyToStatus: Status?
var embeddedStatus: Status?
var customEmojiContainer: [StatusEditorCategorizedEmojiContainer] = []
var postingError: String?
var showPostingErrorAlert: Bool = false
var canPost: Bool {
statusText.length > 0 || !mediaContainers.isEmpty
}
var shouldDisablePollButton: Bool {
!mediaPickers.isEmpty
}
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 {
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))
if let postStatus {
StreamWatcher.shared.emmitPostEvent(for: postStatus)
}
case let .edit(status):
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
if let postStatus {
StreamWatcher.shared.emmitEditEvent(for: postStatus)
}
}
HapticManager.shared.fireHaptic(.notification(.success))
if hasExplicitlySelectedLanguage, let selectedLanguage {
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
}
isPosting = false
return postStatus
} catch {
if let error = error as? Models.ServerError {
postingError = error.error
showPostingErrorAlert = true
}
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 {
StatusEditorMediaContainer(
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 = StatusEditorMediaContainer(id: UUID().uuidString,
image: nil,
movieTransferable: nil,
gifTransferable: .init(url: url),
mediaAttachment: nil,
error: nil)
prepareToPost(for: container)
}
func processCameraPhoto(image: UIImage) {
let container = StatusEditorMediaContainer(
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 {
if let identifier = item.registeredTypeIdentifiers.first,
let handledItemType = StatusEditorUTTypeSupported(rawValue: identifier)
{
do {
let compressor = StatusEditorCompressor()
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 = StatusEditorMediaContainer(
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 = StatusEditorMediaContainer(
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 = StatusEditorMediaContainer(
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 = StatusEditorMediaContainer(
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() {
withAnimation {
tagsSuggestions = []
mentionsSuggestions = []
currentSuggestionRange = nil
showRecentsTagsInline = false
}
}
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: StatusEditorMediaContainer) -> 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: StatusEditorMediaContainer) {
Task(priority: .high) {
self.mediaContainers.append(container)
await upload(container: container)
self.isMediasLoading = false
}
}
func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
await withTaskGroup(of: StatusEditorMediaContainer?.self, returning: StatusEditorMediaContainer?.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 -> StatusEditorMediaContainer? {
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
return StatusEditorMediaContainer(
id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: nil,
movieTransferable: nil,
gifTransferable: gifFile,
mediaAttachment: nil,
error: nil
)
}
private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTranseferable.self) else { return nil }
return StatusEditorMediaContainer(
id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: nil,
movieTransferable: movieFile,
gifTransferable: nil,
mediaAttachment: nil,
error: nil
)
}
private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
let compressor = StatusEditorCompressor()
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
let image = UIImage(data: compressedData)
else { return nil }
return StatusEditorMediaContainer(
id: pickerItem.itemIdentifier ?? UUID().uuidString,
image: image,
movieTransferable: nil,
gifTransferable: nil,
mediaAttachment: nil,
error: nil
)
}
func upload(container: StatusEditorMediaContainer) async {
if let index = indexOf(container: container) {
let originalContainer = mediaContainers[index]
guard originalContainer.mediaAttachment == nil else { return }
let newContainer = StatusEditorMediaContainer(
id: originalContainer.id,
image: originalContainer.image,
movieTransferable: originalContainer.movieTransferable,
gifTransferable: nil,
mediaAttachment: nil,
error: nil
)
mediaContainers[index] = newContainer
do {
let compressor = StatusEditorCompressor()
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] = StatusEditorMediaContainer(
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] = StatusEditorMediaContainer(
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] = StatusEditorMediaContainer(
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] = StatusEditorMediaContainer(
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] = StatusEditorMediaContainer(
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: StatusEditorMediaContainer, 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] = StatusEditorMediaContainer(
id: container.id,
image: nil,
movieTransferable: nil,
gifTransferable: nil,
mediaAttachment: media,
error: nil
)
} catch { print(error) }
}
}
private var mediaAttributes: [StatusData.MediaAttribute] = []
func editDescription(container: StatusEditorMediaContainer, 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 = StatusEditorCategorizedEmojiContainer
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
let category = emoji.category ?? "Uncategorized"
if let emojis = dict[category] {
dict[category] = emojis + [emoji]
} else {
dict[category] = [emoji]
}
return dict
}.sorted(by: { lhs, rhs in
if rhs.key == "Uncategorized" { false }
else if lhs.key == "Uncategorized" { true }
else { lhs.key < rhs.key }
}).forEach { key, value in
emojiContainers.append(.init(categoryName: key, emojis: value))
}
customEmojiContainer = emojiContainers
} catch {}
}
}
// MARK: - DropDelegate
extension StatusEditorViewModel: DropDelegate {
public func performDrop(info: DropInfo) -> Bool {
let item = info.itemProviders(for: StatusEditorUTTypeSupported.types())
processItemsProvider(items: item)
return true
}
}
// MARK: - UITextPasteDelegate
extension StatusEditorViewModel: 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 {}

View File

@ -0,0 +1,141 @@
import Env
import Models
import StoreKit
import SwiftUI
extension StatusEditor {
@MainActor
struct ToolbarItems: ToolbarContent {
@State private var isLanguageConfirmPresented = false
@State private var isDismissAlertPresented: Bool = false
let mainSEVM: ViewModel
let followUpSEVMs: [ViewModel]
@Environment(\.modelContext) private var context
@Environment(UserPreferences.self) private var preferences
#if targetEnvironment(macCatalyst)
@Environment(\.dismissWindow) private var dismissWindow
#else
@Environment(\.dismiss) private var dismiss
#endif
var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
mainSEVM.evaluateLanguages()
if preferences.autoDetectPostLanguage, let _ = mainSEVM.languageConfirmationDialogLanguages {
isLanguageConfirmPresented = true
} else {
await postAllStatus()
}
}
} label: {
if mainSEVM.isPosting {
ProgressView()
} else {
Text("status.action.post").bold()
}
}
.disabled(!mainSEVM.canPost)
.keyboardShortcut(.return, modifiers: .command)
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
languageConfirmationDialog
})
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
if mainSEVM.shouldDisplayDismissWarning {
isDismissAlertPresented = true
} else {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
} label: {
Text("action.cancel")
}
.keyboardShortcut(.cancelAction)
.confirmationDialog(
"",
isPresented: $isDismissAlertPresented,
actions: {
Button("status.draft.delete", role: .destructive) {
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("status.draft.save") {
context.insert(Draft(content: mainSEVM.statusText.string))
close()
NotificationCenter.default.post(name: .shareSheetClose,
object: nil)
}
Button("action.cancel", role: .cancel) {}
}
)
}
}
@discardableResult
private func postStatus(with model: ViewModel, isMainPost: Bool) async -> Status? {
let status = await model.postStatus()
if status != nil, isMainPost {
close()
SoundEffectManager.shared.playSound(.tootSent)
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
#if !targetEnvironment(macCatalyst)
if !mainSEVM.mode.isInShareExtension, !preferences.requestedReview {
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
preferences.requestedReview = true
}
#endif
}
return status
}
private func postAllStatus() async {
guard var latestPost = await postStatus(with: mainSEVM, isMainPost: true) else { return }
for p in followUpSEVMs {
p.mode = .replyTo(status: latestPost)
guard let post = await postStatus(with: p, isMainPost: false) else {
break
}
latestPost = post
}
}
#if targetEnvironment(macCatalyst)
private func close() { dismissWindow() }
#else
private func close() { dismiss() }
#endif
@ViewBuilder
private var languageConfirmationDialog: some View {
if let (detected: detected, selected: selected) = mainSEVM.languageConfirmationDialogLanguages,
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
let selectedLong = Locale.current.localizedString(forLanguageCode: selected)
{
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
mainSEVM.selectedLanguage = detected
Task { await postAllStatus() }
}
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
mainSEVM.selectedLanguage = selected
Task { await postAllStatus() }
}
Button("action.cancel", role: .cancel) {
mainSEVM.languageConfirmationDialogLanguages = nil
}
} else {
EmptyView()
}
}
}
}

View File

@ -2,7 +2,7 @@ import Models
import SwiftUI
import UIKit
public extension StatusEditorViewModel {
public extension StatusEditor.ViewModel {
enum Mode {
case replyTo(status: Status)
case new(visibility: Models.Visibility)

View File

@ -0,0 +1,897 @@
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 = ""
var isPosting: Bool = false
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 {
!mediaPickers.isEmpty
}
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 {
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))
if let postStatus {
StreamWatcher.shared.emmitPostEvent(for: postStatus)
}
case let .edit(status):
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
if let postStatus {
StreamWatcher.shared.emmitEditEvent(for: postStatus)
}
}
HapticManager.shared.fireHaptic(.notification(.success))
if hasExplicitlySelectedLanguage, let selectedLanguage {
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
}
isPosting = false
return postStatus
} catch {
if let error = error as? Models.ServerError {
postingError = error.error
showPostingErrorAlert = true
}
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 {
if let identifier = item.registeredTypeIdentifiers.first,
let handledItemType = UTTypeSupported(rawValue: identifier)
{
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() {
withAnimation {
tagsSuggestions = []
mentionsSuggestions = []
currentSuggestionRange = nil
showRecentsTagsInline = false
}
}
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
}
}
func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
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
let category = emoji.category ?? "Uncategorized"
if let emojis = dict[category] {
dict[category] = emojis + [emoji]
} else {
dict[category] = [emoji]
}
return dict
}.sorted(by: { lhs, rhs in
if rhs.key == "Uncategorized" { false }
else if lhs.key == "Uncategorized" { true }
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 {
let item = info.itemProviders(for: StatusEditor.UTTypeSupported.types())
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 {}