diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index 85bcb88b..9f8f2ca6 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -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): diff --git a/IceCubesApp/App/Main/IceCubesApp+Scene.swift b/IceCubesApp/App/Main/IceCubesApp+Scene.swift index 24815e4b..e9866779 100644 --- a/IceCubesApp/App/Main/IceCubesApp+Scene.swift +++ b/IceCubesApp/App/Main/IceCubesApp+Scene.swift @@ -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() } diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index 063688be..e01cafd7 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -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" -} +} \ No newline at end of file diff --git a/IceCubesShareExtension/ShareViewController.swift b/IceCubesShareExtension/ShareViewController.swift index 5d217184..df639ab0 100644 --- a/IceCubesShareExtension/ShareViewController.swift +++ b/IceCubesShareExtension/ShareViewController.swift @@ -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) diff --git a/Packages/Account/Sources/Account/Edit/EditAccountViewModel.swift b/Packages/Account/Sources/Account/Edit/EditAccountViewModel.swift index de5e41ca..4fb53d85 100644 --- a/Packages/Account/Sources/Account/Edit/EditAccountViewModel.swift +++ b/Packages/Account/Sources/Account/Edit/EditAccountViewModel.swift @@ -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), diff --git a/Packages/Status/Sources/Status/Editor/Components/AIPrompt.swift b/Packages/Status/Sources/Status/Editor/Components/AIPrompt.swift new file mode 100644 index 00000000..bd91de0f --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/AIPrompt.swift @@ -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) + } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/AccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/AccessoryView.swift new file mode 100644 index 00000000..740c860c --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/AccessoryView.swift @@ -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.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 + } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/AutoCompleteView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/AutoCompleteView.swift new file mode 100644 index 00000000..2962495e --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/AutoCompleteView.swift @@ -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) + } + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/ExpandedView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/ExpandedView.swift index 951b6729..59f8faa7 100644 --- a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/ExpandedView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/ExpandedView.swift @@ -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] diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/MentionsView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/MentionsView.swift index 45d5aa2d..d0367de8 100644 --- a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/MentionsView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/MentionsView.swift @@ -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 diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RecentTagsView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RecentTagsView.swift index 67ab9d05..af1d1a3d 100644 --- a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RecentTagsView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RecentTagsView.swift @@ -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] diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RemoteTagsView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RemoteTagsView.swift index df8aa2e5..572ef922 100644 --- a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RemoteTagsView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/RemoteTagsView.swift @@ -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] diff --git a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/StatusEditorAutoCompleteView.swift b/Packages/Status/Sources/Status/Editor/Components/AutoComplete/StatusEditorAutoCompleteView.swift deleted file mode 100644 index 28a5c242..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/AutoComplete/StatusEditorAutoCompleteView.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/CameraPickerView.swift b/Packages/Status/Sources/Status/Editor/Components/CameraPickerView.swift new file mode 100644 index 00000000..bcf51c0a --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/CameraPickerView.swift @@ -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) + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/CategorizedEmojiContainer.swift b/Packages/Status/Sources/Status/Editor/Components/CategorizedEmojiContainer.swift new file mode 100644 index 00000000..a0196636 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/CategorizedEmojiContainer.swift @@ -0,0 +1,11 @@ +import Foundation +import Models + +extension StatusEditor { + struct CategorizedEmojiContainer: Identifiable, Equatable { + let id = UUID().uuidString + let categoryName: String + var emojis: [Emoji] + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/Compressor.swift b/Packages/Status/Sources/Status/Editor/Components/Compressor.swift new file mode 100644 index 00000000..3934fb78 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/Compressor.swift @@ -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) + } + } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/MediaContainer.swift b/Packages/Status/Sources/Status/Editor/Components/MediaContainer.swift new file mode 100644 index 00000000..b6906189 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/MediaContainer.swift @@ -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? + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/MediaEditView.swift b/Packages/Status/Sources/Status/Editor/Components/MediaEditView.swift new file mode 100644 index 00000000..6404de8f --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/MediaEditView.swift @@ -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 + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/MediaView.swift b/Packages/Status/Sources/Status/Editor/Components/MediaView.swift new file mode 100644 index 00000000..869c078d --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/MediaView.swift @@ -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) { + 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) + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/PollView.swift b/Packages/Status/Sources/Status/Editor/Components/PollView.swift new file mode 100644 index 00000000..08fc5a0c --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/PollView.swift @@ -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 + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompt.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompt.swift deleted file mode 100644 index 7a8d4800..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAIPrompt.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift deleted file mode 100644 index 96a899e4..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift +++ /dev/null @@ -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.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 - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCameraPickerView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorCameraPickerView.swift deleted file mode 100644 index b3ca568f..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCameraPickerView.swift +++ /dev/null @@ -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) - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCategorizedEmojiContainer.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorCategorizedEmojiContainer.swift deleted file mode 100644 index 0cc04c6a..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCategorizedEmojiContainer.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation -import Models - -struct StatusEditorCategorizedEmojiContainer: Identifiable, Equatable { - let id = UUID().uuidString - let categoryName: String - var emojis: [Emoji] -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift deleted file mode 100644 index 170638fd..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorCompressor.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift deleted file mode 100644 index b321ba64..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaContainer.swift +++ /dev/null @@ -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? -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift deleted file mode 100644 index 0c359b03..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift +++ /dev/null @@ -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 - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift deleted file mode 100644 index a2518f00..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift +++ /dev/null @@ -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) { - 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) - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorPollView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorPollView.swift deleted file mode 100644 index b4e526ad..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorPollView.swift +++ /dev/null @@ -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 - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift deleted file mode 100644 index 0b644b91..00000000 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorUTTypeSupported.swift +++ /dev/null @@ -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)) - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/Components/UTTypeSupported.swift b/Packages/Status/Sources/Status/Editor/Components/UTTypeSupported.swift new file mode 100644 index 00000000..6f1d3ecb --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Components/UTTypeSupported.swift @@ -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)) + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/Drafts/DraftsListView.swift b/Packages/Status/Sources/Status/Editor/Drafts/DraftsListView.swift index 725e1dd0..e96dcdf4 100644 --- a/Packages/Status/Sources/Status/Editor/Drafts/DraftsListView.swift +++ b/Packages/Status/Sources/Status/Editor/Drafts/DraftsListView.swift @@ -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) } } + } diff --git a/Packages/Status/Sources/Status/Editor/EditorFocusState.swift b/Packages/Status/Sources/Status/Editor/EditorFocusState.swift new file mode 100644 index 00000000..0051a890 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/EditorFocusState.swift @@ -0,0 +1,8 @@ +import SwiftUI + +extension StatusEditor { + enum EditorFocusState: Hashable { + case main, followUp(index: UUID) + } + +} diff --git a/Packages/Status/Sources/Status/Editor/EditorView.swift b/Packages/Status/Sources/Status/Editor/EditorView.swift new file mode 100644 index 00000000..082bd230 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/EditorView.swift @@ -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.Binding var isSpoilerTextFocused: UUID? + @FocusState.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() } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/MainView.swift b/Packages/Status/Sources/Status/Editor/MainView.swift new file mode 100644 index 00000000..87a3c4ff --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/MainView.swift @@ -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)) + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/Namespace.swift b/Packages/Status/Sources/Status/Editor/Namespace.swift new file mode 100644 index 00000000..9a8f7efe --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/Namespace.swift @@ -0,0 +1 @@ +public enum StatusEditor { } diff --git a/Packages/Status/Sources/Status/Editor/PrivacyMenu.swift b/Packages/Status/Sources/Status/Editor/PrivacyMenu.swift new file mode 100644 index 00000000..27d15554 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/PrivacyMenu.swift @@ -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) + ) + } + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift deleted file mode 100644 index c0e918f2..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift +++ /dev/null @@ -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.Binding var isSpoilerTextFocused: UUID? - @FocusState.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() } - } -} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift b/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift deleted file mode 100644 index 1bb54f1c..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift +++ /dev/null @@ -1,5 +0,0 @@ -import SwiftUI - -enum StatusEditorFocusState: Hashable { - case main, followUp(index: UUID) -} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift b/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift deleted file mode 100644 index a3451d99..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift +++ /dev/null @@ -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) - ) - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift b/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift deleted file mode 100644 index 76cc7cf2..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift +++ /dev/null @@ -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() - } - } -} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift deleted file mode 100644 index d881a905..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ /dev/null @@ -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)) - } -} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift deleted file mode 100644 index f3d2e0e4..00000000 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ /dev/null @@ -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? - - 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 {} diff --git a/Packages/Status/Sources/Status/Editor/ToolbarItems.swift b/Packages/Status/Sources/Status/Editor/ToolbarItems.swift new file mode 100644 index 00000000..35527574 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/ToolbarItems.swift @@ -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() + } + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift b/Packages/Status/Sources/Status/Editor/ViewModeMode.swift similarity index 96% rename from Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift rename to Packages/Status/Sources/Status/Editor/ViewModeMode.swift index e14e0955..f53127de 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModelMode.swift +++ b/Packages/Status/Sources/Status/Editor/ViewModeMode.swift @@ -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) diff --git a/Packages/Status/Sources/Status/Editor/ViewModel.swift b/Packages/Status/Sources/Status/Editor/ViewModel.swift new file mode 100644 index 00000000..85dec99a --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/ViewModel.swift @@ -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? + + 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 {}