mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-09-27 14:10:08 +00:00
Namespace StatusEditor
This commit is contained in:
parent
9ade571f53
commit
d65510493a
46 changed files with 2998 additions and 2940 deletions
|
@ -72,15 +72,15 @@ extension View {
|
||||||
Group {
|
Group {
|
||||||
switch destination {
|
switch destination {
|
||||||
case let .replyToStatusEditor(status):
|
case let .replyToStatusEditor(status):
|
||||||
StatusEditorView(mode: .replyTo(status: status))
|
StatusEditor.MainView(mode: .replyTo(status: status))
|
||||||
case let .newStatusEditor(visibility):
|
case let .newStatusEditor(visibility):
|
||||||
StatusEditorView(mode: .new(visibility: visibility))
|
StatusEditor.MainView(mode: .new(visibility: visibility))
|
||||||
case let .editStatusEditor(status):
|
case let .editStatusEditor(status):
|
||||||
StatusEditorView(mode: .edit(status: status))
|
StatusEditor.MainView(mode: .edit(status: status))
|
||||||
case let .quoteStatusEditor(status):
|
case let .quoteStatusEditor(status):
|
||||||
StatusEditorView(mode: .quote(status: status))
|
StatusEditor.MainView(mode: .quote(status: status))
|
||||||
case let .mentionStatusEditor(account, visibility):
|
case let .mentionStatusEditor(account, visibility):
|
||||||
StatusEditorView(mode: .mention(account: account, visibility: visibility))
|
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
|
||||||
case .listCreate:
|
case .listCreate:
|
||||||
ListCreateView()
|
ListCreateView()
|
||||||
case let .listEdit(list):
|
case let .listEdit(list):
|
||||||
|
|
|
@ -69,15 +69,15 @@ extension IceCubesApp {
|
||||||
Group {
|
Group {
|
||||||
switch destination.wrappedValue {
|
switch destination.wrappedValue {
|
||||||
case let .newStatusEditor(visibility):
|
case let .newStatusEditor(visibility):
|
||||||
StatusEditorView(mode: .new(visibility: visibility))
|
StatusEditor.MainView(mode: .new(visibility: visibility))
|
||||||
case let .editStatusEditor(status):
|
case let .editStatusEditor(status):
|
||||||
StatusEditorView(mode: .edit(status: status))
|
StatusEditor.MainView(mode: .edit(status: status))
|
||||||
case let .quoteStatusEditor(status):
|
case let .quoteStatusEditor(status):
|
||||||
StatusEditorView(mode: .quote(status: status))
|
StatusEditor.MainView(mode: .quote(status: status))
|
||||||
case let .replyToStatusEditor(status):
|
case let .replyToStatusEditor(status):
|
||||||
StatusEditorView(mode: .replyTo(status: status))
|
StatusEditor.MainView(mode: .replyTo(status: status))
|
||||||
case let .mentionStatusEditor(account, visibility):
|
case let .mentionStatusEditor(account, visibility):
|
||||||
StatusEditorView(mode: .mention(account: account, visibility: visibility))
|
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
|
||||||
case .none:
|
case .none:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
|
|
@ -21228,7 +21228,7 @@
|
||||||
},
|
},
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%#@count_posts@ posts from %#@count_participants@ participants"
|
"value" : "%#@count_posts@ posts from %#@count_participants@ participants"
|
||||||
},
|
},
|
||||||
"substitutions" : {
|
"substitutions" : {
|
||||||
|
@ -21239,13 +21239,13 @@
|
||||||
"plural" : {
|
"plural" : {
|
||||||
"one" : {
|
"one" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other" : {
|
"other" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21259,13 +21259,13 @@
|
||||||
"plural" : {
|
"plural" : {
|
||||||
"one" : {
|
"one" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other" : {
|
"other" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -67734,7 +67734,7 @@
|
||||||
},
|
},
|
||||||
"en-GB" : {
|
"en-GB" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%#@count_votes@ votes from %#@count_voters@ voters"
|
"value" : "%#@count_votes@ votes from %#@count_voters@ voters"
|
||||||
},
|
},
|
||||||
"substitutions" : {
|
"substitutions" : {
|
||||||
|
@ -67745,13 +67745,13 @@
|
||||||
"plural" : {
|
"plural" : {
|
||||||
"one" : {
|
"one" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other" : {
|
"other" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -67765,13 +67765,13 @@
|
||||||
"plural" : {
|
"plural" : {
|
||||||
"one" : {
|
"one" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other" : {
|
"other" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "translated",
|
||||||
"value" : "%arg"
|
"value" : "%arg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ class ShareViewController: UIViewController {
|
||||||
|
|
||||||
if let item = extensionContext?.inputItems.first as? NSExtensionItem {
|
if let item = extensionContext?.inputItems.first as? NSExtensionItem {
|
||||||
if let attachments = item.attachments {
|
if let attachments = item.attachments {
|
||||||
let view = StatusEditorView(mode: .shareExtension(items: attachments))
|
let view = StatusEditor.MainView(mode: .shareExtension(items: attachments))
|
||||||
.environment(UserPreferences.shared)
|
.environment(UserPreferences.shared)
|
||||||
.environment(appAccountsManager)
|
.environment(appAccountsManager)
|
||||||
.environment(client)
|
.environment(client)
|
||||||
|
|
|
@ -140,9 +140,9 @@ import Status
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getItemImageData(item: PhotosPickerItem) async -> Data? {
|
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),
|
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
|
||||||
let image = UIImage(data: compressedData),
|
let image = UIImage(data: compressedData),
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,466 @@
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
#if !os(visionOS)
|
||||||
|
import GiphyUISDK
|
||||||
|
#endif
|
||||||
|
import Models
|
||||||
|
import NukeUI
|
||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
struct AccessoryView: View {
|
||||||
|
@Environment(UserPreferences.self) private var preferences
|
||||||
|
@Environment(Theme.self) private var theme
|
||||||
|
@Environment(CurrentInstance.self) private var currentInstance
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
|
||||||
|
let focusedSEVM: ViewModel
|
||||||
|
@Binding var followUpSEVMs: [ViewModel]
|
||||||
|
|
||||||
|
@State private var isDraftsSheetDisplayed: Bool = false
|
||||||
|
@State private var isLanguageSheetDisplayed: Bool = false
|
||||||
|
@State private var isCustomEmojisSheetDisplay: Bool = false
|
||||||
|
@State private var languageSearch: String = ""
|
||||||
|
@State private var isLoadingAIRequest: Bool = false
|
||||||
|
@State private var isPhotosPickerPresented: Bool = false
|
||||||
|
@State private var isFileImporterPresented: Bool = false
|
||||||
|
@State private var isCameraPickerPresented: Bool = false
|
||||||
|
@State private var isGIFPickerPresented: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var viewModel = focusedSEVM
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
#if os(visionOS)
|
||||||
|
HStack {
|
||||||
|
contentView
|
||||||
|
}
|
||||||
|
.frame(height: 24)
|
||||||
|
.padding(16)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.cornerRadius(8)
|
||||||
|
#else
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
contentView
|
||||||
|
}
|
||||||
|
.frame(height: 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.setInitialLanguageSelection(preference: preferences.recentlyUsedLanguages.first ?? preferences.serverPreferences?.postLanguage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
#if os(visionOS)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
actionsView
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
actionsView
|
||||||
|
}
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionsView: some View {
|
||||||
|
@Bindable var viewModel = focusedSEVM
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
isPhotosPickerPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("status.editor.photo-library", systemImage: "photo")
|
||||||
|
}
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
Button {
|
||||||
|
isCameraPickerPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("status.editor.camera-picker", systemImage: "camera")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
Button {
|
||||||
|
isFileImporterPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("status.editor.browse-file", systemImage: "folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
Button {
|
||||||
|
isGIFPickerPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("GIPHY", systemImage: "party.popper")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
} label: {
|
||||||
|
if viewModel.isMediasLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "photo.on.rectangle.angled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.photosPicker(isPresented: $isPhotosPickerPresented,
|
||||||
|
selection: $viewModel.mediaPickers,
|
||||||
|
maxSelectionCount: 4,
|
||||||
|
matching: .any(of: [.images, .videos]),
|
||||||
|
photoLibrary: .shared())
|
||||||
|
.fileImporter(isPresented: $isFileImporterPresented,
|
||||||
|
allowedContentTypes: [.image, .video],
|
||||||
|
allowsMultipleSelection: true)
|
||||||
|
{ result in
|
||||||
|
if let urls = try? result.get() {
|
||||||
|
viewModel.processURLs(urls: urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $isCameraPickerPresented, content: {
|
||||||
|
CameraPickerView(selectedImage: .init(get: {
|
||||||
|
nil
|
||||||
|
}, set: { image in
|
||||||
|
if let image {
|
||||||
|
viewModel.processCameraPhoto(image: image)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.background(.black)
|
||||||
|
})
|
||||||
|
.sheet(isPresented: $isGIFPickerPresented, content: {
|
||||||
|
#if !os(visionOS)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
NavigationStack {
|
||||||
|
giphyView
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
isGIFPickerPresented = false
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
#else
|
||||||
|
giphyView
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
|
EmptyView()
|
||||||
|
#endif
|
||||||
|
})
|
||||||
|
.accessibilityLabel("accessibility.editor.button.attach-photo")
|
||||||
|
.disabled(viewModel.showPoll)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
// all SEVM have the same visibility value
|
||||||
|
followUpSEVMs.append(ViewModel(mode: .new(visibility: focusedSEVM.visibility)))
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrowshape.turn.up.left.circle.fill")
|
||||||
|
}
|
||||||
|
.disabled(!canAddNewSEVM)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.showPoll.toggle()
|
||||||
|
viewModel.resetPollDefaults()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chart.bar")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("accessibility.editor.button.poll")
|
||||||
|
.disabled(viewModel.shouldDisablePollButton)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.spoilerOn.toggle()
|
||||||
|
}
|
||||||
|
isSpoilerTextFocused = viewModel.id
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("accessibility.editor.button.spoiler")
|
||||||
|
|
||||||
|
if !viewModel.mode.isInShareExtension {
|
||||||
|
Button {
|
||||||
|
isDraftsSheetDisplayed = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "archivebox")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("accessibility.editor.button.drafts")
|
||||||
|
.popover(isPresented: $isDraftsSheetDisplayed) {
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
|
draftsListView
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
} else {
|
||||||
|
draftsListView
|
||||||
|
.frame(width: 400, height: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.customEmojiContainer.isEmpty {
|
||||||
|
Button {
|
||||||
|
isCustomEmojisSheetDisplay = true
|
||||||
|
} label: {
|
||||||
|
// This is a workaround for an apparent bug in the `face.smiling` SF Symbol.
|
||||||
|
// See https://github.com/Dimillian/IceCubesApp/issues/1193
|
||||||
|
let customEmojiSheetIconName = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
|
||||||
|
Image(systemName: customEmojiSheetIconName)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("accessibility.editor.button.custom-emojis")
|
||||||
|
.popover(isPresented: $isCustomEmojisSheetDisplay) {
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
|
customEmojisSheet
|
||||||
|
} else {
|
||||||
|
customEmojisSheet
|
||||||
|
.frame(width: 400, height: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.insertStatusText(text: "#")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "number")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.insertStatusText(text: "@")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "at")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
isLanguageSheetDisplayed.toggle()
|
||||||
|
} label: {
|
||||||
|
if let language = viewModel.selectedLanguage {
|
||||||
|
Text(language.uppercased())
|
||||||
|
} else {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("accessibility.editor.button.language")
|
||||||
|
.popover(isPresented: $isLanguageSheetDisplayed) {
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
|
languageSheetView
|
||||||
|
} else {
|
||||||
|
languageSheetView
|
||||||
|
.frame(width: 400, height: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferences.isOpenAIEnabled {
|
||||||
|
AIMenu.disabled(!viewModel.canPost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAddNewSEVM: Bool {
|
||||||
|
guard followUpSEVMs.count < 5 else { return false }
|
||||||
|
|
||||||
|
if followUpSEVMs.isEmpty, // there is only mainSEVM on the editor
|
||||||
|
!focusedSEVM.statusText.string.isEmpty // focusedSEVM is also mainSEVM
|
||||||
|
{ return true }
|
||||||
|
|
||||||
|
if let lastSEVMs = followUpSEVMs.last,
|
||||||
|
!lastSEVMs.statusText.string.isEmpty
|
||||||
|
{ return true }
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(visionOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private var giphyView: some View {
|
||||||
|
@Bindable var viewModel = focusedSEVM
|
||||||
|
GifPickerView { url in
|
||||||
|
GPHCache.shared.downloadAssetData(url) { data, _ in
|
||||||
|
guard let data else { return }
|
||||||
|
viewModel.processGIFData(data: data)
|
||||||
|
}
|
||||||
|
isGIFPickerPresented = false
|
||||||
|
} onShouldDismissGifPicker: {
|
||||||
|
isGIFPickerPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var draftsListView: some View {
|
||||||
|
DraftsListView(selectedDraft: .init(get: {
|
||||||
|
nil
|
||||||
|
}, set: { draft in
|
||||||
|
if let draft {
|
||||||
|
focusedSEVM.insertStatusText(text: draft.content)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func languageTextView(isoCode: String, nativeName: String?, name: String?) -> some View {
|
||||||
|
if let nativeName, let name {
|
||||||
|
Text("\(nativeName) (\(name))")
|
||||||
|
} else {
|
||||||
|
Text(isoCode.uppercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var AIMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(AIPrompt.allCases, id: \.self) { prompt in
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isLoadingAIRequest = true
|
||||||
|
await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string))
|
||||||
|
isLoadingAIRequest = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
prompt.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let backup = focusedSEVM.backupStatusText {
|
||||||
|
Button {
|
||||||
|
focusedSEVM.replaceTextWith(text: backup.string)
|
||||||
|
focusedSEVM.backupStatusText = nil
|
||||||
|
} label: {
|
||||||
|
Label("status.editor.restore-previous", systemImage: "arrow.uturn.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isLoadingAIRequest {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "faxmachine")
|
||||||
|
.accessibilityLabel("accessibility.editor.button.ai-prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var languageSheetView: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if languageSearch.isEmpty {
|
||||||
|
if !recentlyUsedLanguages.isEmpty {
|
||||||
|
Section("status.editor.language-select.recently-used") {
|
||||||
|
languageSheetSection(languages: recentlyUsedLanguages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
languageSheetSection(languages: otherLanguages)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
languageSheetSection(languages: languageSearchResult(query: languageSearch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $languageSearch)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("action.cancel", action: { isLanguageSheetDisplayed = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("status.editor.language-select.navigation-title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(theme.secondaryBackgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func languageSheetSection(languages: [Language]) -> some View {
|
||||||
|
ForEach(languages) { language in
|
||||||
|
HStack {
|
||||||
|
languageTextView(
|
||||||
|
isoCode: language.isoCode,
|
||||||
|
nativeName: language.nativeName,
|
||||||
|
name: language.localizedName
|
||||||
|
).tag(language.isoCode)
|
||||||
|
Spacer()
|
||||||
|
if language.isoCode == focusedSEVM.selectedLanguage {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(theme.primaryBackgroundColor)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
focusedSEVM.selectedLanguage = language.isoCode
|
||||||
|
focusedSEVM.hasExplicitlySelectedLanguage = true
|
||||||
|
isLanguageSheetDisplayed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customEmojisSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
ForEach(focusedSEVM.customEmojiContainer) { container in
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(container.categoryName)
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 9) {
|
||||||
|
ForEach(container.emojis) { emoji in
|
||||||
|
LazyImage(url: emoji.url) { state in
|
||||||
|
if let image = state.image {
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.accessibilityLabel(emoji.shortcode.replacingOccurrences(of: "_", with: " "))
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
} else if state.isLoading {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.accessibility(hidden: true)
|
||||||
|
.shimmering()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("action.cancel", action: { isCustomEmojisSheetDisplay = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(theme.primaryBackgroundColor)
|
||||||
|
.navigationTitle("status.editor.emojis.navigation-title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentlyUsedLanguages: [Language] {
|
||||||
|
preferences.recentlyUsedLanguages.compactMap { isoCode in
|
||||||
|
Language.allAvailableLanguages.first { $0.isoCode == isoCode }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var otherLanguages: [Language] {
|
||||||
|
Language.allAvailableLanguages.filter { !preferences.recentlyUsedLanguages.contains($0.isoCode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func languageSearchResult(query: String) -> [Language] {
|
||||||
|
Language.allAvailableLanguages.filter { language in
|
||||||
|
guard !languageSearch.isEmpty else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return language.nativeName?.lowercased().hasPrefix(query.lowercased()) == true
|
||||||
|
|| language.localizedName?.lowercased().hasPrefix(query.lowercased()) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,15 +6,14 @@ import Models
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Env
|
import Env
|
||||||
|
|
||||||
extension StatusEditorAutoCompleteView {
|
extension StatusEditor.AutoCompleteView {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ExpandedView: View {
|
struct ExpandedView: View {
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
@Environment(CurrentAccount.self) private var currentAccount
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
|
|
||||||
var viewModel: StatusEditorViewModel
|
var viewModel: StatusEditor.ViewModel
|
||||||
@Binding var isTagSuggestionExpanded: Bool
|
@Binding var isTagSuggestionExpanded: Bool
|
||||||
|
|
||||||
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
||||||
|
|
|
@ -6,11 +6,11 @@ import Models
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
extension StatusEditorAutoCompleteView {
|
extension StatusEditor.AutoCompleteView {
|
||||||
struct MentionsView: View {
|
struct MentionsView: View {
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
|
|
||||||
var viewModel: StatusEditorViewModel
|
var viewModel: StatusEditor.ViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ForEach(viewModel.mentionsSuggestions) { account in
|
ForEach(viewModel.mentionsSuggestions) { account in
|
||||||
|
|
|
@ -6,11 +6,11 @@ import Models
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
extension StatusEditorAutoCompleteView {
|
extension StatusEditor.AutoCompleteView {
|
||||||
struct RecentTagsView: View {
|
struct RecentTagsView: View {
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
|
|
||||||
var viewModel: StatusEditorViewModel
|
var viewModel: StatusEditor.ViewModel
|
||||||
@Binding var isTagSuggestionExpanded: Bool
|
@Binding var isTagSuggestionExpanded: Bool
|
||||||
|
|
||||||
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
||||||
|
|
|
@ -6,12 +6,12 @@ import Models
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
extension StatusEditorAutoCompleteView {
|
extension StatusEditor.AutoCompleteView {
|
||||||
struct RemoteTagsView: View {
|
struct RemoteTagsView: View {
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@Environment(Theme.self) private var theme
|
@Environment(Theme.self) private var theme
|
||||||
|
|
||||||
var viewModel: StatusEditorViewModel
|
var viewModel: StatusEditor.ViewModel
|
||||||
@Binding var isTagSuggestionExpanded: Bool
|
@Binding var isTagSuggestionExpanded: Bool
|
||||||
|
|
||||||
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
@Query(sort: \RecentTag.lastUse, order: .reverse) var recentTags: [RecentTag]
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import Foundation
|
||||||
|
import Models
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
struct CategorizedEmojiContainer: Identifiable, Equatable {
|
||||||
|
let id = UUID().uuidString
|
||||||
|
let categoryName: String
|
||||||
|
var emojis: [Emoji]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
252
Packages/Status/Sources/Status/Editor/Components/MediaView.swift
Normal file
252
Packages/Status/Sources/Status/Editor/Components/MediaView.swift
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
import AVKit
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
import MediaUI
|
||||||
|
import Models
|
||||||
|
import NukeUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
struct MediaView: View {
|
||||||
|
@Environment(Theme.self) private var theme
|
||||||
|
@Environment(CurrentInstance.self) private var currentInstance
|
||||||
|
var viewModel: ViewModel
|
||||||
|
@Binding var editingMediaContainer: MediaContainer?
|
||||||
|
|
||||||
|
@State private var isErrorDisplayed: Bool = false
|
||||||
|
|
||||||
|
@Namespace var mediaSpace
|
||||||
|
@State private var scrollID: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: showsScrollIndicators) {
|
||||||
|
switch count {
|
||||||
|
case 1: mediaLayout
|
||||||
|
case 2: mediaLayout
|
||||||
|
case 3: mediaLayout
|
||||||
|
case 4: mediaLayout
|
||||||
|
default: mediaLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollPosition(id: $scrollID, anchor: .trailing)
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
.frame(height: count > 0 ? containerHeight : 0)
|
||||||
|
.animation(.spring(duration: 0.3), value: count)
|
||||||
|
.onChange(of: count) { oldValue, newValue in
|
||||||
|
if oldValue < newValue {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(0.5))
|
||||||
|
withAnimation(.bouncy(duration: 0.5)) {
|
||||||
|
scrollID = containers.last?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var count: Int { viewModel.mediaContainers.count }
|
||||||
|
private var containers: [MediaContainer] { viewModel.mediaContainers }
|
||||||
|
private let containerHeight: CGFloat = 300
|
||||||
|
private var containerWidth: CGFloat { containerHeight / 1.5 }
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private var showsScrollIndicators: Bool { count > 1 }
|
||||||
|
private var scrollBottomPadding: CGFloat?
|
||||||
|
#else
|
||||||
|
private var showsScrollIndicators: Bool = false
|
||||||
|
private var scrollBottomPadding: CGFloat? = 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(viewModel: ViewModel, editingMediaContainer: Binding<StatusEditor.MediaContainer?>) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
_editingMediaContainer = editingMediaContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pixel(at index: Int) -> some View {
|
||||||
|
Rectangle().frame(width: 0, height: 0)
|
||||||
|
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediaLayout: some View {
|
||||||
|
HStack(alignment: .center, spacing: count > 1 ? 8 : 0) {
|
||||||
|
if count > 0 {
|
||||||
|
if count == 1 {
|
||||||
|
makeMediaItem(at: 0)
|
||||||
|
.containerRelativeFrame(.horizontal, alignment: .leading)
|
||||||
|
} else {
|
||||||
|
makeMediaItem(at: 0)
|
||||||
|
}
|
||||||
|
} else { pixel(at: 0) }
|
||||||
|
if count > 1 { makeMediaItem(at: 1) } else { pixel(at: 1) }
|
||||||
|
if count > 2 { makeMediaItem(at: 2) } else { pixel(at: 2) }
|
||||||
|
if count > 3 { makeMediaItem(at: 3) } else { pixel(at: 3) }
|
||||||
|
}
|
||||||
|
.padding(.bottom, scrollBottomPadding)
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeMediaItem(at index: Int) -> some View {
|
||||||
|
let container = viewModel.mediaContainers[index]
|
||||||
|
|
||||||
|
return Menu {
|
||||||
|
makeImageMenu(container: container)
|
||||||
|
} label: {
|
||||||
|
RoundedRectangle(cornerRadius: 8).fill(.clear)
|
||||||
|
.overlay {
|
||||||
|
if let attachement = container.mediaAttachment {
|
||||||
|
makeRemoteMediaView(mediaAttachement: attachement)
|
||||||
|
} else if container.image != nil {
|
||||||
|
makeLocalImageView(container: container)
|
||||||
|
} else if let error = container.error as? ServerError {
|
||||||
|
makeErrorView(error: error)
|
||||||
|
} else {
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
makeAltMarker(container: container)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
makeDiscardMarker(container: container)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.frame(minWidth: count == 1 ? nil : containerWidth, maxWidth: 600)
|
||||||
|
.id(container.id)
|
||||||
|
.matchedGeometryEffect(id: container.id, in: mediaSpace, anchor: .leading)
|
||||||
|
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLocalImageView(container: MediaContainer) -> some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Image(uiImage: container.image!)
|
||||||
|
.resizable()
|
||||||
|
.blur(radius: container.mediaAttachment == nil ? 20 : 0)
|
||||||
|
.scaledToFill()
|
||||||
|
.cornerRadius(8)
|
||||||
|
if container.error != nil {
|
||||||
|
Text("status.editor.error.upload")
|
||||||
|
} else if container.mediaAttachment == nil {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRemoteMediaView(mediaAttachement: MediaAttachment) -> some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
switch mediaAttachement.supportedType {
|
||||||
|
case .gifv, .video, .audio:
|
||||||
|
if let url = mediaAttachement.url {
|
||||||
|
MediaUIAttachmentVideoView(viewModel: .init(url: url, forceAutoPlay: true))
|
||||||
|
} else {
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
case .image:
|
||||||
|
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
|
||||||
|
LazyImage(url: url) { state in
|
||||||
|
if let image = state.image {
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .none:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func makeImageMenu(container: MediaContainer) -> some View {
|
||||||
|
if container.mediaAttachment?.url != nil {
|
||||||
|
if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing {
|
||||||
|
Button {
|
||||||
|
editingMediaContainer = container
|
||||||
|
} label: {
|
||||||
|
Label(container.mediaAttachment?.description?.isEmpty == false ?
|
||||||
|
"status.editor.description.edit" : "status.editor.description.add",
|
||||||
|
systemImage: "pencil.line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if container.error != nil {
|
||||||
|
Button {
|
||||||
|
isErrorDisplayed = true
|
||||||
|
} label: {
|
||||||
|
Label("action.view.error", systemImage: "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteAction(container: container)
|
||||||
|
} label: {
|
||||||
|
Label("action.delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeErrorView(error: ServerError) -> some View {
|
||||||
|
ZStack {
|
||||||
|
placeholderView
|
||||||
|
Text("status.editor.error.upload")
|
||||||
|
}
|
||||||
|
.alert("alert.error", isPresented: $isErrorDisplayed) {
|
||||||
|
Button("Ok", action: {})
|
||||||
|
} message: {
|
||||||
|
Text(error.error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAltMarker(container: MediaContainer) -> some View {
|
||||||
|
Button {
|
||||||
|
editingMediaContainer = container
|
||||||
|
} label: {
|
||||||
|
Text("status.image.alt-text.abbreviation")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDiscardMarker(container: MediaContainer) -> some View {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteAction(container: container)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.padding(8)
|
||||||
|
.background(Circle().fill(.thinMaterial))
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAction(container: MediaContainer) {
|
||||||
|
viewModel.mediaPickers.removeAll(where: {
|
||||||
|
if let id = $0.itemIdentifier {
|
||||||
|
return id == container.id
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
viewModel.mediaContainers.removeAll {
|
||||||
|
$0.id == container.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderView: some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(theme.secondaryBackgroundColor)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
125
Packages/Status/Sources/Status/Editor/Components/PollView.swift
Normal file
125
Packages/Status/Sources/Status/Editor/Components/PollView.swift
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
struct PollView: View {
|
||||||
|
enum FocusField: Hashable {
|
||||||
|
case option(Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
@FocusState var focused: FocusField?
|
||||||
|
|
||||||
|
@State private var currentFocusIndex: Int = 0
|
||||||
|
|
||||||
|
@Environment(Theme.self) private var theme
|
||||||
|
@Environment(CurrentInstance.self) private var currentInstance
|
||||||
|
|
||||||
|
var viewModel: ViewModel
|
||||||
|
|
||||||
|
@Binding var showPoll: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var viewModel = viewModel
|
||||||
|
let count = viewModel.pollOptions.count
|
||||||
|
VStack {
|
||||||
|
ForEach(0 ..< count, id: \.self) { index in
|
||||||
|
VStack {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
TextField("status.poll.option-n \(index + 1)", text: $viewModel.pollOptions[index])
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.focused($focused, equals: .option(index))
|
||||||
|
.onTapGesture {
|
||||||
|
if canAddMoreAt(index) {
|
||||||
|
currentFocusIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSubmit {
|
||||||
|
if canAddMoreAt(index) {
|
||||||
|
addChoice(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if canAddMoreAt(index) {
|
||||||
|
Button {
|
||||||
|
addChoice(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
removeChoice(at: index)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
focused = .option(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Picker("status.poll.frequency", selection: $viewModel.pollVotingFrequency) {
|
||||||
|
ForEach(PollVotingFrequency.allCases, id: \.rawValue) {
|
||||||
|
Text($0.displayString)
|
||||||
|
.tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layoutPriority(1.0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Picker("status.poll.duration", selection: $viewModel.pollDuration) {
|
||||||
|
ForEach(Duration.pollDurations(), id: \.rawValue) {
|
||||||
|
Text($0.description)
|
||||||
|
.tag($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6.0)
|
||||||
|
.stroke(theme.secondaryBackgroundColor.opacity(0.6), lineWidth: 1)
|
||||||
|
.background(theme.primaryBackgroundColor.opacity(0.3))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addChoice(at index: Int) {
|
||||||
|
viewModel.pollOptions.append("")
|
||||||
|
currentFocusIndex = index + 1
|
||||||
|
moveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeChoice(at index: Int) {
|
||||||
|
viewModel.pollOptions.remove(at: index)
|
||||||
|
|
||||||
|
if viewModel.pollOptions.count == 1 {
|
||||||
|
viewModel.resetPollDefaults()
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
showPoll = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveFocus() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||||
|
focused = .option(currentFocusIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canAddMoreAt(_ index: Int) -> Bool {
|
||||||
|
let count = viewModel.pollOptions.count
|
||||||
|
let maxEntries: Int = currentInstance.instance?.configuration?.polls.maxOptions ?? 4
|
||||||
|
|
||||||
|
return index == count - 1 && count < maxEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,463 +0,0 @@
|
||||||
import DesignSystem
|
|
||||||
import Env
|
|
||||||
#if !os(visionOS)
|
|
||||||
import GiphyUISDK
|
|
||||||
#endif
|
|
||||||
import Models
|
|
||||||
import NukeUI
|
|
||||||
import PhotosUI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StatusEditorAccessoryView: View {
|
|
||||||
@Environment(UserPreferences.self) private var preferences
|
|
||||||
@Environment(Theme.self) private var theme
|
|
||||||
@Environment(CurrentInstance.self) private var currentInstance
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
|
|
||||||
let focusedSEVM: StatusEditorViewModel
|
|
||||||
@Binding var followUpSEVMs: [StatusEditorViewModel]
|
|
||||||
|
|
||||||
@State private var isDraftsSheetDisplayed: Bool = false
|
|
||||||
@State private var isLanguageSheetDisplayed: Bool = false
|
|
||||||
@State private var isCustomEmojisSheetDisplay: Bool = false
|
|
||||||
@State private var languageSearch: String = ""
|
|
||||||
@State private var isLoadingAIRequest: Bool = false
|
|
||||||
@State private var isPhotosPickerPresented: Bool = false
|
|
||||||
@State private var isFileImporterPresented: Bool = false
|
|
||||||
@State private var isCameraPickerPresented: Bool = false
|
|
||||||
@State private var isGIFPickerPresented: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
@Bindable var viewModel = focusedSEVM
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
#if os(visionOS)
|
|
||||||
HStack {
|
|
||||||
contentView
|
|
||||||
}
|
|
||||||
.frame(height: 24)
|
|
||||||
.padding(16)
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.cornerRadius(8)
|
|
||||||
#else
|
|
||||||
Divider()
|
|
||||||
HStack {
|
|
||||||
contentView
|
|
||||||
}
|
|
||||||
.frame(height: 20)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(.thinMaterial)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
viewModel.setInitialLanguageSelection(preference: preferences.recentlyUsedLanguages.first ?? preferences.serverPreferences?.postLanguage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contentView: some View {
|
|
||||||
#if os(visionOS)
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
actionsView
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(alignment: .center, spacing: 16) {
|
|
||||||
actionsView
|
|
||||||
}
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var actionsView: some View {
|
|
||||||
@Bindable var viewModel = focusedSEVM
|
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
isPhotosPickerPresented = true
|
|
||||||
} label: {
|
|
||||||
Label("status.editor.photo-library", systemImage: "photo")
|
|
||||||
}
|
|
||||||
#if !targetEnvironment(macCatalyst)
|
|
||||||
Button {
|
|
||||||
isCameraPickerPresented = true
|
|
||||||
} label: {
|
|
||||||
Label("status.editor.camera-picker", systemImage: "camera")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Button {
|
|
||||||
isFileImporterPresented = true
|
|
||||||
} label: {
|
|
||||||
Label("status.editor.browse-file", systemImage: "folder")
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
Button {
|
|
||||||
isGIFPickerPresented = true
|
|
||||||
} label: {
|
|
||||||
Label("GIPHY", systemImage: "party.popper")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} label: {
|
|
||||||
if viewModel.isMediasLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "photo.on.rectangle.angled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.photosPicker(isPresented: $isPhotosPickerPresented,
|
|
||||||
selection: $viewModel.mediaPickers,
|
|
||||||
maxSelectionCount: 4,
|
|
||||||
matching: .any(of: [.images, .videos]),
|
|
||||||
photoLibrary: .shared())
|
|
||||||
.fileImporter(isPresented: $isFileImporterPresented,
|
|
||||||
allowedContentTypes: [.image, .video],
|
|
||||||
allowsMultipleSelection: true)
|
|
||||||
{ result in
|
|
||||||
if let urls = try? result.get() {
|
|
||||||
viewModel.processURLs(urls: urls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $isCameraPickerPresented, content: {
|
|
||||||
StatusEditorCameraPickerView(selectedImage: .init(get: {
|
|
||||||
nil
|
|
||||||
}, set: { image in
|
|
||||||
if let image {
|
|
||||||
viewModel.processCameraPhoto(image: image)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.background(.black)
|
|
||||||
})
|
|
||||||
.sheet(isPresented: $isGIFPickerPresented, content: {
|
|
||||||
#if !os(visionOS)
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
NavigationStack {
|
|
||||||
giphyView
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
Button {
|
|
||||||
isGIFPickerPresented = false
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
#else
|
|
||||||
giphyView
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
#endif
|
|
||||||
#else
|
|
||||||
EmptyView()
|
|
||||||
#endif
|
|
||||||
})
|
|
||||||
.accessibilityLabel("accessibility.editor.button.attach-photo")
|
|
||||||
.disabled(viewModel.showPoll)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
// all SEVM have the same visibility value
|
|
||||||
followUpSEVMs.append(StatusEditorViewModel(mode: .new(visibility: focusedSEVM.visibility)))
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrowshape.turn.up.left.circle.fill")
|
|
||||||
}
|
|
||||||
.disabled(!canAddNewSEVM)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showPoll.toggle()
|
|
||||||
viewModel.resetPollDefaults()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chart.bar")
|
|
||||||
}
|
|
||||||
.accessibilityLabel("accessibility.editor.button.poll")
|
|
||||||
.disabled(viewModel.shouldDisablePollButton)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.spoilerOn.toggle()
|
|
||||||
}
|
|
||||||
isSpoilerTextFocused = viewModel.id
|
|
||||||
} label: {
|
|
||||||
Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle")
|
|
||||||
}
|
|
||||||
.accessibilityLabel("accessibility.editor.button.spoiler")
|
|
||||||
|
|
||||||
if !viewModel.mode.isInShareExtension {
|
|
||||||
Button {
|
|
||||||
isDraftsSheetDisplayed = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "archivebox")
|
|
||||||
}
|
|
||||||
.accessibilityLabel("accessibility.editor.button.drafts")
|
|
||||||
.popover(isPresented: $isDraftsSheetDisplayed) {
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
||||||
draftsListView
|
|
||||||
.presentationDetents([.medium])
|
|
||||||
} else {
|
|
||||||
draftsListView
|
|
||||||
.frame(width: 400, height: 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.customEmojiContainer.isEmpty {
|
|
||||||
Button {
|
|
||||||
isCustomEmojisSheetDisplay = true
|
|
||||||
} label: {
|
|
||||||
// This is a workaround for an apparent bug in the `face.smiling` SF Symbol.
|
|
||||||
// See https://github.com/Dimillian/IceCubesApp/issues/1193
|
|
||||||
let customEmojiSheetIconName = colorScheme == .light ? "face.smiling" : "face.smiling.inverse"
|
|
||||||
Image(systemName: customEmojiSheetIconName)
|
|
||||||
}
|
|
||||||
.accessibilityLabel("accessibility.editor.button.custom-emojis")
|
|
||||||
.popover(isPresented: $isCustomEmojisSheetDisplay) {
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
||||||
customEmojisSheet
|
|
||||||
} else {
|
|
||||||
customEmojisSheet
|
|
||||||
.frame(width: 400, height: 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.insertStatusText(text: "#")
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "number")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.insertStatusText(text: "@")
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "at")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
isLanguageSheetDisplayed.toggle()
|
|
||||||
} label: {
|
|
||||||
if let language = viewModel.selectedLanguage {
|
|
||||||
Text(language.uppercased())
|
|
||||||
} else {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityLabel("accessibility.editor.button.language")
|
|
||||||
.popover(isPresented: $isLanguageSheetDisplayed) {
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
||||||
languageSheetView
|
|
||||||
} else {
|
|
||||||
languageSheetView
|
|
||||||
.frame(width: 400, height: 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if preferences.isOpenAIEnabled {
|
|
||||||
AIMenu.disabled(!viewModel.canPost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canAddNewSEVM: Bool {
|
|
||||||
guard followUpSEVMs.count < 5 else { return false }
|
|
||||||
|
|
||||||
if followUpSEVMs.isEmpty, // there is only mainSEVM on the editor
|
|
||||||
!focusedSEVM.statusText.string.isEmpty // focusedSEVM is also mainSEVM
|
|
||||||
{ return true }
|
|
||||||
|
|
||||||
if let lastSEVMs = followUpSEVMs.last,
|
|
||||||
!lastSEVMs.statusText.string.isEmpty
|
|
||||||
{ return true }
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(visionOS)
|
|
||||||
@ViewBuilder
|
|
||||||
private var giphyView: some View {
|
|
||||||
@Bindable var viewModel = focusedSEVM
|
|
||||||
GifPickerView { url in
|
|
||||||
GPHCache.shared.downloadAssetData(url) { data, _ in
|
|
||||||
guard let data else { return }
|
|
||||||
viewModel.processGIFData(data: data)
|
|
||||||
}
|
|
||||||
isGIFPickerPresented = false
|
|
||||||
} onShouldDismissGifPicker: {
|
|
||||||
isGIFPickerPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var draftsListView: some View {
|
|
||||||
DraftsListView(selectedDraft: .init(get: {
|
|
||||||
nil
|
|
||||||
}, set: { draft in
|
|
||||||
if let draft {
|
|
||||||
focusedSEVM.insertStatusText(text: draft.content)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func languageTextView(isoCode: String, nativeName: String?, name: String?) -> some View {
|
|
||||||
if let nativeName, let name {
|
|
||||||
Text("\(nativeName) (\(name))")
|
|
||||||
} else {
|
|
||||||
Text(isoCode.uppercased())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var AIMenu: some View {
|
|
||||||
Menu {
|
|
||||||
ForEach(StatusEditorAIPrompt.allCases, id: \.self) { prompt in
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
isLoadingAIRequest = true
|
|
||||||
await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string))
|
|
||||||
isLoadingAIRequest = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
prompt.label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let backup = focusedSEVM.backupStatusText {
|
|
||||||
Button {
|
|
||||||
focusedSEVM.replaceTextWith(text: backup.string)
|
|
||||||
focusedSEVM.backupStatusText = nil
|
|
||||||
} label: {
|
|
||||||
Label("status.editor.restore-previous", systemImage: "arrow.uturn.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if isLoadingAIRequest {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "faxmachine")
|
|
||||||
.accessibilityLabel("accessibility.editor.button.ai-prompt")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var languageSheetView: some View {
|
|
||||||
NavigationStack {
|
|
||||||
List {
|
|
||||||
if languageSearch.isEmpty {
|
|
||||||
if !recentlyUsedLanguages.isEmpty {
|
|
||||||
Section("status.editor.language-select.recently-used") {
|
|
||||||
languageSheetSection(languages: recentlyUsedLanguages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
languageSheetSection(languages: otherLanguages)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
languageSheetSection(languages: languageSearchResult(query: languageSearch))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $languageSearch)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button("action.cancel", action: { isLanguageSheetDisplayed = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("status.editor.language-select.navigation-title")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(theme.secondaryBackgroundColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func languageSheetSection(languages: [Language]) -> some View {
|
|
||||||
ForEach(languages) { language in
|
|
||||||
HStack {
|
|
||||||
languageTextView(
|
|
||||||
isoCode: language.isoCode,
|
|
||||||
nativeName: language.nativeName,
|
|
||||||
name: language.localizedName
|
|
||||||
).tag(language.isoCode)
|
|
||||||
Spacer()
|
|
||||||
if language.isoCode == focusedSEVM.selectedLanguage {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(theme.primaryBackgroundColor)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
focusedSEVM.selectedLanguage = language.isoCode
|
|
||||||
focusedSEVM.hasExplicitlySelectedLanguage = true
|
|
||||||
isLanguageSheetDisplayed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var customEmojisSheet: some View {
|
|
||||||
NavigationStack {
|
|
||||||
ScrollView {
|
|
||||||
ForEach(focusedSEVM.customEmojiContainer) { container in
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(container.categoryName)
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 9) {
|
|
||||||
ForEach(container.emojis) { emoji in
|
|
||||||
LazyImage(url: emoji.url) { state in
|
|
||||||
if let image = state.image {
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.accessibilityLabel(emoji.shortcode.replacingOccurrences(of: "_", with: " "))
|
|
||||||
.accessibilityAddTraits(.isButton)
|
|
||||||
} else if state.isLoading {
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.gray)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.accessibility(hidden: true)
|
|
||||||
.shimmering()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button("action.cancel", action: { isCustomEmojisSheetDisplay = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(theme.primaryBackgroundColor)
|
|
||||||
.navigationTitle("status.editor.emojis.navigation-title")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
.presentationDetents([.medium])
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recentlyUsedLanguages: [Language] {
|
|
||||||
preferences.recentlyUsedLanguages.compactMap { isoCode in
|
|
||||||
Language.allAvailableLanguages.first { $0.isoCode == isoCode }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var otherLanguages: [Language] {
|
|
||||||
Language.allAvailableLanguages.filter { !preferences.recentlyUsedLanguages.contains($0.isoCode) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func languageSearchResult(query: String) -> [Language] {
|
|
||||||
Language.allAvailableLanguages.filter { language in
|
|
||||||
guard !languageSearch.isEmpty else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return language.nativeName?.lowercased().hasPrefix(query.lowercased()) == true
|
|
||||||
|| language.localizedName?.lowercased().hasPrefix(query.lowercased()) == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Models
|
|
||||||
|
|
||||||
struct StatusEditorCategorizedEmojiContainer: Identifiable, Equatable {
|
|
||||||
let id = UUID().uuidString
|
|
||||||
let categoryName: String
|
|
||||||
var emojis: [Emoji]
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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?
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,249 +0,0 @@
|
||||||
import AVKit
|
|
||||||
import DesignSystem
|
|
||||||
import Env
|
|
||||||
import MediaUI
|
|
||||||
import Models
|
|
||||||
import NukeUI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StatusEditorMediaView: View {
|
|
||||||
@Environment(Theme.self) private var theme
|
|
||||||
@Environment(CurrentInstance.self) private var currentInstance
|
|
||||||
var viewModel: StatusEditorViewModel
|
|
||||||
@Binding var editingMediaContainer: StatusEditorMediaContainer?
|
|
||||||
|
|
||||||
@State private var isErrorDisplayed: Bool = false
|
|
||||||
|
|
||||||
@Namespace var mediaSpace
|
|
||||||
@State private var scrollID: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(.horizontal, showsIndicators: showsScrollIndicators) {
|
|
||||||
switch count {
|
|
||||||
case 1: mediaLayout
|
|
||||||
case 2: mediaLayout
|
|
||||||
case 3: mediaLayout
|
|
||||||
case 4: mediaLayout
|
|
||||||
default: mediaLayout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollPosition(id: $scrollID, anchor: .trailing)
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
.frame(height: count > 0 ? containerHeight : 0)
|
|
||||||
.animation(.spring(duration: 0.3), value: count)
|
|
||||||
.onChange(of: count) { oldValue, newValue in
|
|
||||||
if oldValue < newValue {
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(for: .seconds(0.5))
|
|
||||||
withAnimation(.bouncy(duration: 0.5)) {
|
|
||||||
scrollID = containers.last?.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var count: Int { viewModel.mediaContainers.count }
|
|
||||||
private var containers: [StatusEditorMediaContainer] { viewModel.mediaContainers }
|
|
||||||
private let containerHeight: CGFloat = 300
|
|
||||||
private var containerWidth: CGFloat { containerHeight / 1.5 }
|
|
||||||
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
private var showsScrollIndicators: Bool { count > 1 }
|
|
||||||
private var scrollBottomPadding: CGFloat?
|
|
||||||
#else
|
|
||||||
private var showsScrollIndicators: Bool = false
|
|
||||||
private var scrollBottomPadding: CGFloat? = 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(viewModel: StatusEditorViewModel, editingMediaContainer: Binding<StatusEditorMediaContainer?>) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
_editingMediaContainer = editingMediaContainer
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pixel(at index: Int) -> some View {
|
|
||||||
Rectangle().frame(width: 0, height: 0)
|
|
||||||
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mediaLayout: some View {
|
|
||||||
HStack(alignment: .center, spacing: count > 1 ? 8 : 0) {
|
|
||||||
if count > 0 {
|
|
||||||
if count == 1 {
|
|
||||||
makeMediaItem(at: 0)
|
|
||||||
.containerRelativeFrame(.horizontal, alignment: .leading)
|
|
||||||
} else {
|
|
||||||
makeMediaItem(at: 0)
|
|
||||||
}
|
|
||||||
} else { pixel(at: 0) }
|
|
||||||
if count > 1 { makeMediaItem(at: 1) } else { pixel(at: 1) }
|
|
||||||
if count > 2 { makeMediaItem(at: 2) } else { pixel(at: 2) }
|
|
||||||
if count > 3 { makeMediaItem(at: 3) } else { pixel(at: 3) }
|
|
||||||
}
|
|
||||||
.padding(.bottom, scrollBottomPadding)
|
|
||||||
.scrollTargetLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeMediaItem(at index: Int) -> some View {
|
|
||||||
let container = viewModel.mediaContainers[index]
|
|
||||||
|
|
||||||
return Menu {
|
|
||||||
makeImageMenu(container: container)
|
|
||||||
} label: {
|
|
||||||
RoundedRectangle(cornerRadius: 8).fill(.clear)
|
|
||||||
.overlay {
|
|
||||||
if let attachement = container.mediaAttachment {
|
|
||||||
makeRemoteMediaView(mediaAttachement: attachement)
|
|
||||||
} else if container.image != nil {
|
|
||||||
makeLocalImageView(container: container)
|
|
||||||
} else if let error = container.error as? ServerError {
|
|
||||||
makeErrorView(error: error)
|
|
||||||
} else {
|
|
||||||
placeholderView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay(alignment: .bottomTrailing) {
|
|
||||||
makeAltMarker(container: container)
|
|
||||||
}
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
makeDiscardMarker(container: container)
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(minWidth: count == 1 ? nil : containerWidth, maxWidth: 600)
|
|
||||||
.id(container.id)
|
|
||||||
.matchedGeometryEffect(id: container.id, in: mediaSpace, anchor: .leading)
|
|
||||||
.matchedGeometryEffect(id: index, in: mediaSpace, anchor: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeLocalImageView(container: StatusEditorMediaContainer) -> some View {
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
Image(uiImage: container.image!)
|
|
||||||
.resizable()
|
|
||||||
.blur(radius: container.mediaAttachment == nil ? 20 : 0)
|
|
||||||
.scaledToFill()
|
|
||||||
.cornerRadius(8)
|
|
||||||
if container.error != nil {
|
|
||||||
Text("status.editor.error.upload")
|
|
||||||
} else if container.mediaAttachment == nil {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeRemoteMediaView(mediaAttachement: MediaAttachment) -> some View {
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
switch mediaAttachement.supportedType {
|
|
||||||
case .gifv, .video, .audio:
|
|
||||||
if let url = mediaAttachement.url {
|
|
||||||
MediaUIAttachmentVideoView(viewModel: .init(url: url, forceAutoPlay: true))
|
|
||||||
} else {
|
|
||||||
placeholderView
|
|
||||||
}
|
|
||||||
case .image:
|
|
||||||
if let url = mediaAttachement.url ?? mediaAttachement.previewUrl {
|
|
||||||
LazyImage(url: url) { state in
|
|
||||||
if let image = state.image {
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
} else {
|
|
||||||
placeholderView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .none:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func makeImageMenu(container: StatusEditorMediaContainer) -> some View {
|
|
||||||
if container.mediaAttachment?.url != nil {
|
|
||||||
if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing {
|
|
||||||
Button {
|
|
||||||
editingMediaContainer = container
|
|
||||||
} label: {
|
|
||||||
Label(container.mediaAttachment?.description?.isEmpty == false ?
|
|
||||||
"status.editor.description.edit" : "status.editor.description.add",
|
|
||||||
systemImage: "pencil.line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if container.error != nil {
|
|
||||||
Button {
|
|
||||||
isErrorDisplayed = true
|
|
||||||
} label: {
|
|
||||||
Label("action.view.error", systemImage: "exclamationmark.triangle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
deleteAction(container: container)
|
|
||||||
} label: {
|
|
||||||
Label("action.delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeErrorView(error: ServerError) -> some View {
|
|
||||||
ZStack {
|
|
||||||
placeholderView
|
|
||||||
Text("status.editor.error.upload")
|
|
||||||
}
|
|
||||||
.alert("alert.error", isPresented: $isErrorDisplayed) {
|
|
||||||
Button("Ok", action: {})
|
|
||||||
} message: {
|
|
||||||
Text(error.error ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeAltMarker(container: StatusEditorMediaContainer) -> some View {
|
|
||||||
Button {
|
|
||||||
editingMediaContainer = container
|
|
||||||
} label: {
|
|
||||||
Text("status.image.alt-text.abbreviation")
|
|
||||||
.font(.caption2)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeDiscardMarker(container: StatusEditorMediaContainer) -> some View {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
deleteAction(container: container)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.padding(8)
|
|
||||||
.background(Circle().fill(.thinMaterial))
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteAction(container: StatusEditorMediaContainer) {
|
|
||||||
viewModel.mediaPickers.removeAll(where: {
|
|
||||||
if let id = $0.itemIdentifier {
|
|
||||||
return id == container.id
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
viewModel.mediaContainers.removeAll {
|
|
||||||
$0.id == container.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholderView: some View {
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(theme.secondaryBackgroundColor)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,50 +3,53 @@ import Models
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DraftsListView: View {
|
extension StatusEditor {
|
||||||
@Environment(\.dismiss) private var dismiss
|
struct DraftsListView: View {
|
||||||
@Environment(\.modelContext) private var context
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(drafts) { draft in
|
ForEach(drafts) { draft in
|
||||||
Button {
|
Button {
|
||||||
selectedDraft = draft
|
selectedDraft = draft
|
||||||
dismiss()
|
dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(draft.content)
|
Text(draft.content)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
.foregroundStyle(theme.labelColor)
|
.foregroundStyle(theme.labelColor)
|
||||||
Text(draft.creationDate, style: .relative)
|
Text(draft.creationDate, style: .relative)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.gray)
|
.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 {
|
||||||
.toolbar {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
Button("action.cancel", action: { dismiss() })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
enum EditorFocusState: Hashable {
|
||||||
|
case main, followUp(index: UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
183
Packages/Status/Sources/Status/Editor/EditorView.swift
Normal file
183
Packages/Status/Sources/Status/Editor/EditorView.swift
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import AppAccount
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
import Models
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
struct EditorView: View {
|
||||||
|
@Bindable var viewModel: ViewModel
|
||||||
|
@Binding var followUpSEVMs: [ViewModel]
|
||||||
|
@Binding var editingMediaContainer: MediaContainer?
|
||||||
|
|
||||||
|
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
|
||||||
|
@FocusState<EditorFocusState?>.Binding var editorFocusState: EditorFocusState?
|
||||||
|
let assignedFocusState: EditorFocusState
|
||||||
|
let isMain: Bool
|
||||||
|
|
||||||
|
@Environment(Theme.self) private var theme
|
||||||
|
@Environment(UserPreferences.self) private var preferences
|
||||||
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
|
@Environment(CurrentInstance.self) private var currentInstance
|
||||||
|
@Environment(AppAccountsManager.self) private var appAccounts
|
||||||
|
@Environment(Client.self) private var client
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@Environment(\.dismissWindow) private var dismissWindow
|
||||||
|
#else
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
if !isMain {
|
||||||
|
Rectangle()
|
||||||
|
.fill(theme.tintColor)
|
||||||
|
.frame(width: 2)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
.padding(.leading, .layoutPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
spoilerTextView
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
accountHeaderView
|
||||||
|
textInput
|
||||||
|
characterCountView
|
||||||
|
MediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer)
|
||||||
|
embeddedStatus
|
||||||
|
pollView
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
.opacity(editorFocusState == assignedFocusState ? 1 : 0.6)
|
||||||
|
}
|
||||||
|
#if !os(visionOS)
|
||||||
|
.background(theme.primaryBackgroundColor)
|
||||||
|
#endif
|
||||||
|
.focused($editorFocusState, equals: assignedFocusState)
|
||||||
|
.onAppear { setupViewModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var spoilerTextView: some View {
|
||||||
|
if viewModel.spoilerOn {
|
||||||
|
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
|
||||||
|
.focused($isSpoilerTextFocused, equals: viewModel.id)
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
.padding(.vertical)
|
||||||
|
.background(theme.tintColor.opacity(0.20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var accountHeaderView: some View {
|
||||||
|
if let account = currentAccount.account, !viewModel.mode.isEditing {
|
||||||
|
HStack {
|
||||||
|
if viewModel.mode.isInShareExtension {
|
||||||
|
AppAccountsSelectorView(routerPath: RouterPath(),
|
||||||
|
accountCreationEnabled: false,
|
||||||
|
avatarConfig: .status)
|
||||||
|
} else {
|
||||||
|
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
|
||||||
|
.environment(theme)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
PrivacyMenu(visibility: $viewModel.visibility, tint: isMain ? theme.tintColor : .secondary)
|
||||||
|
.disabled(!isMain)
|
||||||
|
|
||||||
|
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if case let .followUp(id) = assignedFocusState {
|
||||||
|
Button {
|
||||||
|
followUpSEVMs.removeAll { $0.id == id }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textInput: some View {
|
||||||
|
TextView(
|
||||||
|
$viewModel.statusText,
|
||||||
|
getTextView: { textView in viewModel.textView = textView }
|
||||||
|
)
|
||||||
|
.placeholder(String(localized: isMain ? "status.editor.text.placeholder" : "status.editor.follow-up.text.placeholder"))
|
||||||
|
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var embeddedStatus: some View {
|
||||||
|
if viewModel.replyToStatus != nil { Divider().padding(.top, 20) }
|
||||||
|
|
||||||
|
if let status = viewModel.embeddedStatus ?? viewModel.replyToStatus {
|
||||||
|
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
|
||||||
|
.padding(.horizontal, .layoutPadding)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pollView: some View {
|
||||||
|
if viewModel.showPoll {
|
||||||
|
PollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var characterCountView: some View {
|
||||||
|
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(value)")
|
||||||
|
.foregroundColor(value < 0 ? .red : .secondary)
|
||||||
|
.font(.scaledCallout)
|
||||||
|
.accessibilityLabel("accessibility.editor.button.characters-remaining")
|
||||||
|
.accessibilityValue("\(value)")
|
||||||
|
.accessibilityRemoveTraits(.isStaticText)
|
||||||
|
.accessibilityAddTraits(.updatesFrequently)
|
||||||
|
.accessibilityRespondsToUserInteraction(false)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupViewModel() {
|
||||||
|
viewModel.client = client
|
||||||
|
viewModel.currentAccount = currentAccount.account
|
||||||
|
viewModel.theme = theme
|
||||||
|
viewModel.preferences = preferences
|
||||||
|
viewModel.prepareStatusText()
|
||||||
|
if !client.isAuth {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
dismissWindow()
|
||||||
|
#else
|
||||||
|
dismiss()
|
||||||
|
#endif
|
||||||
|
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { await viewModel.fetchCustomEmojis() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
150
Packages/Status/Sources/Status/Editor/MainView.swift
Normal file
150
Packages/Status/Sources/Status/Editor/MainView.swift
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
import AppAccount
|
||||||
|
import DesignSystem
|
||||||
|
import EmojiText
|
||||||
|
import Env
|
||||||
|
import Models
|
||||||
|
import Network
|
||||||
|
import NukeUI
|
||||||
|
import PhotosUI
|
||||||
|
import StoreKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
public struct MainView: View {
|
||||||
|
@Environment(AppAccountsManager.self) private var appAccounts
|
||||||
|
@Environment(CurrentAccount.self) private var currentAccount
|
||||||
|
@Environment(Theme.self) private var theme
|
||||||
|
|
||||||
|
@State private var presentationDetent: PresentationDetent = .large
|
||||||
|
@State private var mainSEVM: ViewModel
|
||||||
|
@State private var followUpSEVMs: [ViewModel] = []
|
||||||
|
@FocusState private var isSpoilerTextFocused: UUID? // connect CoreEditor and StatusEditorAccessoryView
|
||||||
|
@State private var editingMediaContainer: MediaContainer?
|
||||||
|
@State private var scrollID: UUID?
|
||||||
|
|
||||||
|
@FocusState private var editorFocusState: EditorFocusState?
|
||||||
|
|
||||||
|
private var focusedSEVM: ViewModel {
|
||||||
|
if case let .followUp(id) = editorFocusState,
|
||||||
|
let sevm = followUpSEVMs.first(where: { $0.id == id })
|
||||||
|
{ return sevm }
|
||||||
|
|
||||||
|
return mainSEVM
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(mode: ViewModel.Mode) {
|
||||||
|
_mainSEVM = State(initialValue: ViewModel(mode: mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
@Bindable var focusedSEVM = focusedSEVM
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStackLayout(spacing: 0) {
|
||||||
|
EditorView(
|
||||||
|
viewModel: mainSEVM,
|
||||||
|
followUpSEVMs: $followUpSEVMs,
|
||||||
|
editingMediaContainer: $editingMediaContainer,
|
||||||
|
isSpoilerTextFocused: $isSpoilerTextFocused,
|
||||||
|
editorFocusState: $editorFocusState,
|
||||||
|
assignedFocusState: .main,
|
||||||
|
isMain: true
|
||||||
|
)
|
||||||
|
.id(mainSEVM.id)
|
||||||
|
|
||||||
|
ForEach(followUpSEVMs) { sevm in
|
||||||
|
@Bindable var sevm: ViewModel = sevm
|
||||||
|
|
||||||
|
EditorView(
|
||||||
|
viewModel: sevm,
|
||||||
|
followUpSEVMs: $followUpSEVMs,
|
||||||
|
editingMediaContainer: $editingMediaContainer,
|
||||||
|
isSpoilerTextFocused: $isSpoilerTextFocused,
|
||||||
|
editorFocusState: $editorFocusState,
|
||||||
|
assignedFocusState: .followUp(index: sevm.id),
|
||||||
|
isMain: false
|
||||||
|
)
|
||||||
|
.id(sevm.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
.scrollPosition(id: $scrollID, anchor: .top)
|
||||||
|
.animation(.bouncy(duration: 0.3), value: editorFocusState)
|
||||||
|
.animation(.bouncy(duration: 0.3), value: followUpSEVMs)
|
||||||
|
#if !os(visionOS)
|
||||||
|
.background(theme.primaryBackgroundColor)
|
||||||
|
#endif
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
AutoCompleteView(viewModel: focusedSEVM)
|
||||||
|
}
|
||||||
|
#if os(visionOS)
|
||||||
|
.ornament(attachmentAnchor: .scene(.bottom)) {
|
||||||
|
AccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if presentationDetent == .large || presentationDetent == .medium {
|
||||||
|
AccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views
|
||||||
|
.navigationTitle(focusedSEVM.mode.title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItems(mainSEVM: mainSEVM, followUpSEVMs: followUpSEVMs) }
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.alert(
|
||||||
|
"status.error.posting.title",
|
||||||
|
isPresented: $focusedSEVM.showPostingErrorAlert,
|
||||||
|
actions: {
|
||||||
|
Button("OK") {}
|
||||||
|
}, message: {
|
||||||
|
Text(mainSEVM.postingError ?? "")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.interactiveDismissDisabled(mainSEVM.shouldDisplayDismissWarning)
|
||||||
|
.onChange(of: appAccounts.currentClient) { _, newValue in
|
||||||
|
if mainSEVM.mode.isInShareExtension {
|
||||||
|
currentAccount.setClient(client: newValue)
|
||||||
|
mainSEVM.client = newValue
|
||||||
|
for post in followUpSEVMs {
|
||||||
|
post.client = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDrop(of: StatusEditor.UTTypeSupported.types(), delegate: focusedSEVM)
|
||||||
|
.onChange(of: currentAccount.account?.id) {
|
||||||
|
mainSEVM.currentAccount = currentAccount.account
|
||||||
|
for p in followUpSEVMs {
|
||||||
|
p.currentAccount = mainSEVM.currentAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: mainSEVM.visibility) {
|
||||||
|
for p in followUpSEVMs {
|
||||||
|
p.visibility = mainSEVM.visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: followUpSEVMs.count) { oldValue, newValue in
|
||||||
|
if oldValue < newValue {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(0.1))
|
||||||
|
withAnimation(.bouncy(duration: 0.5)) {
|
||||||
|
scrollID = followUpSEVMs.last?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingMediaContainer) { container in
|
||||||
|
StatusEditor.MediaEditView(viewModel: focusedSEVM, container: container)
|
||||||
|
}
|
||||||
|
.presentationDetents([.large, .medium, .height(50)], selection: $presentationDetent)
|
||||||
|
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
1
Packages/Status/Sources/Status/Editor/Namespace.swift
Normal file
1
Packages/Status/Sources/Status/Editor/Namespace.swift
Normal file
|
@ -0,0 +1 @@
|
||||||
|
public enum StatusEditor { }
|
34
Packages/Status/Sources/Status/Editor/PrivacyMenu.swift
Normal file
34
Packages/Status/Sources/Status/Editor/PrivacyMenu.swift
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import Models
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
struct PrivacyMenu: View {
|
||||||
|
@Binding var visibility: Models.Visibility
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(Models.Visibility.allCases, id: \.self) { vis in
|
||||||
|
Button { visibility = vis } label: {
|
||||||
|
Label(vis.title, systemImage: vis.iconName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(visibility.title, systemImage: visibility.iconName)
|
||||||
|
.accessibilityLabel("accessibility.editor.privacy.label")
|
||||||
|
.accessibilityValue(visibility.title)
|
||||||
|
.accessibilityHint("accessibility.editor.privacy.hint")
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
}
|
||||||
|
.font(.scaledFootnote)
|
||||||
|
.padding(4)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(tint, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,180 +0,0 @@
|
||||||
import AppAccount
|
|
||||||
import DesignSystem
|
|
||||||
import Env
|
|
||||||
import Models
|
|
||||||
import Network
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct StatusEditorCoreView: View {
|
|
||||||
@Bindable var viewModel: StatusEditorViewModel
|
|
||||||
@Binding var followUpSEVMs: [StatusEditorViewModel]
|
|
||||||
@Binding var editingMediaContainer: StatusEditorMediaContainer?
|
|
||||||
|
|
||||||
@FocusState<UUID?>.Binding var isSpoilerTextFocused: UUID?
|
|
||||||
@FocusState<StatusEditorFocusState?>.Binding var editorFocusState: StatusEditorFocusState?
|
|
||||||
let assignedFocusState: StatusEditorFocusState
|
|
||||||
let isMain: Bool
|
|
||||||
|
|
||||||
@Environment(Theme.self) private var theme
|
|
||||||
@Environment(UserPreferences.self) private var preferences
|
|
||||||
@Environment(CurrentAccount.self) private var currentAccount
|
|
||||||
@Environment(CurrentInstance.self) private var currentInstance
|
|
||||||
@Environment(AppAccountsManager.self) private var appAccounts
|
|
||||||
@Environment(Client.self) private var client
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
@Environment(\.dismissWindow) private var dismissWindow
|
|
||||||
#else
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
if !isMain {
|
|
||||||
Rectangle()
|
|
||||||
.fill(theme.tintColor)
|
|
||||||
.frame(width: 2)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
.padding(.leading, .layoutPadding)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
spoilerTextView
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
accountHeaderView
|
|
||||||
textInput
|
|
||||||
characterCountView
|
|
||||||
StatusEditorMediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer)
|
|
||||||
embeddedStatus
|
|
||||||
pollView
|
|
||||||
}
|
|
||||||
.padding(.vertical)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
.opacity(editorFocusState == assignedFocusState ? 1 : 0.6)
|
|
||||||
}
|
|
||||||
#if !os(visionOS)
|
|
||||||
.background(theme.primaryBackgroundColor)
|
|
||||||
#endif
|
|
||||||
.focused($editorFocusState, equals: assignedFocusState)
|
|
||||||
.onAppear { setupViewModel() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var spoilerTextView: some View {
|
|
||||||
if viewModel.spoilerOn {
|
|
||||||
TextField("status.editor.spoiler", text: $viewModel.spoilerText)
|
|
||||||
.focused($isSpoilerTextFocused, equals: viewModel.id)
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
.padding(.vertical)
|
|
||||||
.background(theme.tintColor.opacity(0.20))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var accountHeaderView: some View {
|
|
||||||
if let account = currentAccount.account, !viewModel.mode.isEditing {
|
|
||||||
HStack {
|
|
||||||
if viewModel.mode.isInShareExtension {
|
|
||||||
AppAccountsSelectorView(routerPath: RouterPath(),
|
|
||||||
accountCreationEnabled: false,
|
|
||||||
avatarConfig: .status)
|
|
||||||
} else {
|
|
||||||
AvatarView(account.avatar, config: AvatarView.FrameConfig.status)
|
|
||||||
.environment(theme)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
StatusEditorPrivacyMenu(visibility: $viewModel.visibility, tint: isMain ? theme.tintColor : .secondary)
|
|
||||||
.disabled(!isMain)
|
|
||||||
|
|
||||||
Text("@\(account.acct)@\(appAccounts.currentClient.server)")
|
|
||||||
.font(.scaledFootnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if case let .followUp(id) = assignedFocusState {
|
|
||||||
Button {
|
|
||||||
followUpSEVMs.removeAll { $0.id == id }
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textInput: some View {
|
|
||||||
TextView(
|
|
||||||
$viewModel.statusText,
|
|
||||||
getTextView: { textView in viewModel.textView = textView }
|
|
||||||
)
|
|
||||||
.placeholder(String(localized: isMain ? "status.editor.text.placeholder" : "status.editor.follow-up.text.placeholder"))
|
|
||||||
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var embeddedStatus: some View {
|
|
||||||
if viewModel.replyToStatus != nil { Divider().padding(.top, 20) }
|
|
||||||
|
|
||||||
if let status = viewModel.embeddedStatus ?? viewModel.replyToStatus {
|
|
||||||
StatusEmbeddedView(status: status, client: client, routerPath: RouterPath())
|
|
||||||
.padding(.horizontal, .layoutPadding)
|
|
||||||
.disabled(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var pollView: some View {
|
|
||||||
if viewModel.showPoll {
|
|
||||||
StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var characterCountView: some View {
|
|
||||||
let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Text("\(value)")
|
|
||||||
.foregroundColor(value < 0 ? .red : .secondary)
|
|
||||||
.font(.scaledCallout)
|
|
||||||
.accessibilityLabel("accessibility.editor.button.characters-remaining")
|
|
||||||
.accessibilityValue("\(value)")
|
|
||||||
.accessibilityRemoveTraits(.isStaticText)
|
|
||||||
.accessibilityAddTraits(.updatesFrequently)
|
|
||||||
.accessibilityRespondsToUserInteraction(false)
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupViewModel() {
|
|
||||||
viewModel.client = client
|
|
||||||
viewModel.currentAccount = currentAccount.account
|
|
||||||
viewModel.theme = theme
|
|
||||||
viewModel.preferences = preferences
|
|
||||||
viewModel.prepareStatusText()
|
|
||||||
if !client.isAuth {
|
|
||||||
#if targetEnvironment(macCatalyst)
|
|
||||||
dismissWindow()
|
|
||||||
#else
|
|
||||||
dismiss()
|
|
||||||
#endif
|
|
||||||
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
Task { await viewModel.fetchCustomEmojis() }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
enum StatusEditorFocusState: Hashable {
|
|
||||||
case main, followUp(index: UUID)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,894 +0,0 @@
|
||||||
import Combine
|
|
||||||
import DesignSystem
|
|
||||||
import Env
|
|
||||||
import Models
|
|
||||||
import NaturalLanguage
|
|
||||||
import Network
|
|
||||||
import PhotosUI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable public class StatusEditorViewModel: NSObject, Identifiable {
|
|
||||||
public let id = UUID()
|
|
||||||
|
|
||||||
var mode: Mode
|
|
||||||
|
|
||||||
var client: Client?
|
|
||||||
var currentAccount: Account? {
|
|
||||||
didSet {
|
|
||||||
if let itemsProvider {
|
|
||||||
mediaContainers = []
|
|
||||||
processItemsProvider(items: itemsProvider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var theme: Theme?
|
|
||||||
var preferences: UserPreferences?
|
|
||||||
var languageConfirmationDialogLanguages: (detected: String, selected: String)?
|
|
||||||
|
|
||||||
var textView: UITextView? {
|
|
||||||
didSet {
|
|
||||||
textView?.pasteDelegate = self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedRange: NSRange {
|
|
||||||
get {
|
|
||||||
guard let textView else {
|
|
||||||
return .init(location: 0, length: 0)
|
|
||||||
}
|
|
||||||
return textView.selectedRange
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
textView?.selectedRange = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var markedTextRange: UITextRange? {
|
|
||||||
guard let textView else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return textView.markedTextRange
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusText = NSMutableAttributedString(string: "") {
|
|
||||||
didSet {
|
|
||||||
let range = selectedRange
|
|
||||||
processText()
|
|
||||||
checkEmbed()
|
|
||||||
textView?.attributedText = statusText
|
|
||||||
selectedRange = range
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var urlLengthAdjustments: Int = 0
|
|
||||||
private let maxLengthOfUrl = 23
|
|
||||||
|
|
||||||
private var spoilerTextCount: Int {
|
|
||||||
spoilerOn ? spoilerText.utf16.count : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusTextCharacterLength: Int {
|
|
||||||
urlLengthAdjustments - statusText.string.utf16.count - spoilerTextCount
|
|
||||||
}
|
|
||||||
|
|
||||||
private var itemsProvider: [NSItemProvider]?
|
|
||||||
|
|
||||||
var backupStatusText: NSAttributedString?
|
|
||||||
|
|
||||||
var showPoll: Bool = false
|
|
||||||
var pollVotingFrequency = PollVotingFrequency.oneVote
|
|
||||||
var pollDuration = Duration.oneDay
|
|
||||||
var pollOptions: [String] = ["", ""]
|
|
||||||
|
|
||||||
var spoilerOn: Bool = false
|
|
||||||
var spoilerText: String = ""
|
|
||||||
|
|
||||||
var isPosting: Bool = false
|
|
||||||
var mediaPickers: [PhotosPickerItem] = [] {
|
|
||||||
didSet {
|
|
||||||
if mediaPickers.count > 4 {
|
|
||||||
mediaPickers = mediaPickers.prefix(4).map { $0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
let removedIDs = oldValue
|
|
||||||
.filter { !mediaPickers.contains($0) }
|
|
||||||
.compactMap(\.itemIdentifier)
|
|
||||||
mediaContainers.removeAll { removedIDs.contains($0.id) }
|
|
||||||
|
|
||||||
let newPickerItems = mediaPickers.filter { !oldValue.contains($0) }
|
|
||||||
if !newPickerItems.isEmpty {
|
|
||||||
isMediasLoading = true
|
|
||||||
for item in newPickerItems {
|
|
||||||
prepareToPost(for: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isMediasLoading: Bool = false
|
|
||||||
|
|
||||||
var mediaContainers: [StatusEditorMediaContainer] = []
|
|
||||||
var replyToStatus: Status?
|
|
||||||
var embeddedStatus: Status?
|
|
||||||
|
|
||||||
var customEmojiContainer: [StatusEditorCategorizedEmojiContainer] = []
|
|
||||||
|
|
||||||
var postingError: String?
|
|
||||||
var showPostingErrorAlert: Bool = false
|
|
||||||
|
|
||||||
var canPost: Bool {
|
|
||||||
statusText.length > 0 || !mediaContainers.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldDisablePollButton: Bool {
|
|
||||||
!mediaPickers.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldDisplayDismissWarning: Bool {
|
|
||||||
var modifiedStatusText = statusText.string.trimmingCharacters(in: .whitespaces)
|
|
||||||
|
|
||||||
if let mentionString, modifiedStatusText.hasPrefix(mentionString) {
|
|
||||||
modifiedStatusText = String(modifiedStatusText.dropFirst(mentionString.count))
|
|
||||||
}
|
|
||||||
|
|
||||||
return !modifiedStatusText.isEmpty && !mode.isInShareExtension
|
|
||||||
}
|
|
||||||
|
|
||||||
var visibility: Models.Visibility = .pub
|
|
||||||
|
|
||||||
var mentionsSuggestions: [Account] = []
|
|
||||||
var tagsSuggestions: [Tag] = []
|
|
||||||
var showRecentsTagsInline: Bool = false
|
|
||||||
var selectedLanguage: String?
|
|
||||||
var hasExplicitlySelectedLanguage: Bool = false
|
|
||||||
private var currentSuggestionRange: NSRange?
|
|
||||||
|
|
||||||
private var embeddedStatusURL: URL? {
|
|
||||||
URL(string: embeddedStatus?.reblog?.url ?? embeddedStatus?.url ?? "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mentionString: String?
|
|
||||||
|
|
||||||
private var suggestedTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(mode: Mode) {
|
|
||||||
self.mode = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
func setInitialLanguageSelection(preference: String?) {
|
|
||||||
switch mode {
|
|
||||||
case let .edit(status), let .quote(status):
|
|
||||||
selectedLanguage = status.language
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedLanguage = selectedLanguage ?? preference ?? currentAccount?.source?.language
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluateLanguages() {
|
|
||||||
if let detectedLang = detectLanguage(text: statusText.string),
|
|
||||||
let selectedLanguage,
|
|
||||||
selectedLanguage != "",
|
|
||||||
selectedLanguage != detectedLang
|
|
||||||
{
|
|
||||||
languageConfirmationDialogLanguages = (detected: detectedLang, selected: selectedLanguage)
|
|
||||||
} else {
|
|
||||||
languageConfirmationDialogLanguages = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postStatus() async -> Status? {
|
|
||||||
guard let client else { return nil }
|
|
||||||
do {
|
|
||||||
isPosting = true
|
|
||||||
let postStatus: Status?
|
|
||||||
var pollData: StatusData.PollData?
|
|
||||||
if let pollOptions = getPollOptionsForAPI() {
|
|
||||||
pollData = .init(options: pollOptions,
|
|
||||||
multiple: pollVotingFrequency.canVoteMultipleTimes,
|
|
||||||
expires_in: pollDuration.rawValue)
|
|
||||||
}
|
|
||||||
let data = StatusData(status: statusText.string,
|
|
||||||
visibility: visibility,
|
|
||||||
inReplyToId: mode.replyToStatus?.id,
|
|
||||||
spoilerText: spoilerOn ? spoilerText : nil,
|
|
||||||
mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id },
|
|
||||||
poll: pollData,
|
|
||||||
language: selectedLanguage,
|
|
||||||
mediaAttributes: mediaAttributes)
|
|
||||||
switch mode {
|
|
||||||
case .new, .replyTo, .quote, .mention, .shareExtension:
|
|
||||||
postStatus = try await client.post(endpoint: Statuses.postStatus(json: data))
|
|
||||||
if let postStatus {
|
|
||||||
StreamWatcher.shared.emmitPostEvent(for: postStatus)
|
|
||||||
}
|
|
||||||
case let .edit(status):
|
|
||||||
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
|
|
||||||
if let postStatus {
|
|
||||||
StreamWatcher.shared.emmitEditEvent(for: postStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HapticManager.shared.fireHaptic(.notification(.success))
|
|
||||||
if hasExplicitlySelectedLanguage, let selectedLanguage {
|
|
||||||
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
|
|
||||||
}
|
|
||||||
isPosting = false
|
|
||||||
return postStatus
|
|
||||||
} catch {
|
|
||||||
if let error = error as? Models.ServerError {
|
|
||||||
postingError = error.error
|
|
||||||
showPostingErrorAlert = true
|
|
||||||
}
|
|
||||||
isPosting = false
|
|
||||||
HapticManager.shared.fireHaptic(.notification(.error))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Status Text manipulations
|
|
||||||
|
|
||||||
func insertStatusText(text: String) {
|
|
||||||
let string = statusText
|
|
||||||
string.mutableString.insert(text, at: selectedRange.location)
|
|
||||||
statusText = string
|
|
||||||
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
|
|
||||||
processText()
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceTextWith(text: String, inRange: NSRange) {
|
|
||||||
let string = statusText
|
|
||||||
string.mutableString.deleteCharacters(in: inRange)
|
|
||||||
string.mutableString.insert(text, at: inRange.location)
|
|
||||||
statusText = string
|
|
||||||
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceTextWith(text: String) {
|
|
||||||
statusText = .init(string: text)
|
|
||||||
selectedRange = .init(location: text.utf16.count, length: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareStatusText() {
|
|
||||||
switch mode {
|
|
||||||
case let .new(visibility):
|
|
||||||
self.visibility = visibility
|
|
||||||
case let .shareExtension(items):
|
|
||||||
itemsProvider = items
|
|
||||||
visibility = .pub
|
|
||||||
processItemsProvider(items: items)
|
|
||||||
case let .replyTo(status):
|
|
||||||
var mentionString = ""
|
|
||||||
if (status.reblog?.account.acct ?? status.account.acct) != currentAccount?.acct {
|
|
||||||
mentionString = "@\(status.reblog?.account.acct ?? status.account.acct)"
|
|
||||||
}
|
|
||||||
for mention in status.mentions where mention.acct != currentAccount?.acct {
|
|
||||||
if !mentionString.isEmpty {
|
|
||||||
mentionString += " "
|
|
||||||
}
|
|
||||||
mentionString += "@\(mention.acct)"
|
|
||||||
}
|
|
||||||
if !mentionString.isEmpty {
|
|
||||||
mentionString += " "
|
|
||||||
}
|
|
||||||
replyToStatus = status
|
|
||||||
visibility = UserPreferences.shared.getReplyVisibility(of: status)
|
|
||||||
statusText = .init(string: mentionString)
|
|
||||||
selectedRange = .init(location: mentionString.utf16.count, length: 0)
|
|
||||||
if !mentionString.isEmpty {
|
|
||||||
self.mentionString = mentionString.trimmingCharacters(in: .whitespaces)
|
|
||||||
}
|
|
||||||
if !status.spoilerText.asRawText.isEmpty {
|
|
||||||
spoilerOn = true
|
|
||||||
spoilerText = status.spoilerText.asRawText
|
|
||||||
}
|
|
||||||
case let .mention(account, visibility):
|
|
||||||
statusText = .init(string: "@\(account.acct) ")
|
|
||||||
self.visibility = visibility
|
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
|
||||||
case let .edit(status):
|
|
||||||
var rawText = status.content.asRawText.escape()
|
|
||||||
for mention in status.mentions {
|
|
||||||
rawText = rawText.replacingOccurrences(of: "@\(mention.username)", with: "@\(mention.acct)")
|
|
||||||
}
|
|
||||||
statusText = .init(string: rawText)
|
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
|
||||||
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
|
||||||
spoilerText = status.spoilerText.asRawText
|
|
||||||
visibility = status.visibility
|
|
||||||
mediaContainers = status.mediaAttachments.map {
|
|
||||||
StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: $0,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case let .quote(status):
|
|
||||||
embeddedStatus = status
|
|
||||||
if let url = embeddedStatusURL {
|
|
||||||
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
|
||||||
selectedRange = .init(location: 0, length: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processText() {
|
|
||||||
guard markedTextRange == nil else { return }
|
|
||||||
statusText.addAttributes([.foregroundColor: UIColor(Theme.shared.labelColor),
|
|
||||||
.font: Font.scaledBodyUIFont,
|
|
||||||
.backgroundColor: UIColor.clear,
|
|
||||||
.underlineColor: UIColor.clear],
|
|
||||||
range: NSMakeRange(0, statusText.string.utf16.count))
|
|
||||||
let hashtagPattern = "(#+[\\w0-9(_)]{0,})"
|
|
||||||
let mentionPattern = "(@+[a-zA-Z0-9(_).-]{1,})"
|
|
||||||
let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)"
|
|
||||||
|
|
||||||
do {
|
|
||||||
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
|
||||||
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
|
||||||
let urlRegex = try NSRegularExpression(pattern: urlPattern, options: [])
|
|
||||||
|
|
||||||
let range = NSMakeRange(0, statusText.string.utf16.count)
|
|
||||||
var ranges = hashtagRegex.matches(in: statusText.string,
|
|
||||||
options: [],
|
|
||||||
range: range).map(\.range)
|
|
||||||
ranges.append(contentsOf: mentionRegex.matches(in: statusText.string,
|
|
||||||
options: [],
|
|
||||||
range: range).map(\.range))
|
|
||||||
|
|
||||||
let urlRanges = urlRegex.matches(in: statusText.string,
|
|
||||||
options: [],
|
|
||||||
range: range).map(\.range)
|
|
||||||
|
|
||||||
var foundSuggestionRange = false
|
|
||||||
for nsRange in ranges {
|
|
||||||
statusText.addAttributes([.foregroundColor: UIColor(theme?.tintColor ?? .brand)],
|
|
||||||
range: nsRange)
|
|
||||||
if selectedRange.location == (nsRange.location + nsRange.length),
|
|
||||||
let range = Range(nsRange, in: statusText.string)
|
|
||||||
{
|
|
||||||
foundSuggestionRange = true
|
|
||||||
currentSuggestionRange = nsRange
|
|
||||||
loadAutoCompleteResults(query: String(statusText.string[range]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundSuggestionRange || ranges.isEmpty {
|
|
||||||
resetAutoCompletion()
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalUrlLength = 0
|
|
||||||
var numUrls = 0
|
|
||||||
|
|
||||||
for range in urlRanges {
|
|
||||||
numUrls += 1
|
|
||||||
totalUrlLength += range.length
|
|
||||||
|
|
||||||
statusText.addAttributes([.foregroundColor: UIColor(theme?.tintColor ?? .brand),
|
|
||||||
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
|
||||||
.underlineColor: UIColor(theme?.tintColor ?? .brand)],
|
|
||||||
range: NSRange(location: range.location, length: range.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
urlLengthAdjustments = totalUrlLength - (maxLengthOfUrl * numUrls)
|
|
||||||
|
|
||||||
statusText.enumerateAttributes(in: range) { attributes, range, _ in
|
|
||||||
if attributes[.link] != nil {
|
|
||||||
statusText.removeAttribute(.link, range: range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shar sheet / Item provider
|
|
||||||
|
|
||||||
func processURLs(urls: [URL]) {
|
|
||||||
isMediasLoading = true
|
|
||||||
let items = urls.filter { $0.startAccessingSecurityScopedResource() }
|
|
||||||
.compactMap { NSItemProvider(contentsOf: $0) }
|
|
||||||
processItemsProvider(items: items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func processGIFData(data: Data) {
|
|
||||||
isMediasLoading = true
|
|
||||||
let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).gif")
|
|
||||||
try? data.write(to: url)
|
|
||||||
let container = StatusEditorMediaContainer(id: UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: .init(url: url),
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
}
|
|
||||||
|
|
||||||
func processCameraPhoto(image: UIImage) {
|
|
||||||
let container = StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: image,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processItemsProvider(items: [NSItemProvider]) {
|
|
||||||
Task {
|
|
||||||
var initialText: String = ""
|
|
||||||
for item in items {
|
|
||||||
if let identifier = item.registeredTypeIdentifiers.first,
|
|
||||||
let handledItemType = StatusEditorUTTypeSupported(rawValue: identifier)
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
let compressor = StatusEditorCompressor()
|
|
||||||
let content = try await handledItemType.loadItemContent(item: item)
|
|
||||||
if let text = content as? String {
|
|
||||||
initialText += "\(text) "
|
|
||||||
} else if let image = content as? UIImage {
|
|
||||||
let container = StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: image,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
} else if let content = content as? ImageFileTranseferable,
|
|
||||||
let compressedData = await compressor.compressImageFrom(url: content.url),
|
|
||||||
let image = UIImage(data: compressedData)
|
|
||||||
{
|
|
||||||
let container = StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: image,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
} else if let video = content as? MovieFileTranseferable {
|
|
||||||
let container = StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: video,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
} else if let gif = content as? GifFileTranseferable {
|
|
||||||
let container = StatusEditorMediaContainer(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: gif,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
prepareToPost(for: container)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
isMediasLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !initialText.isEmpty {
|
|
||||||
statusText = .init(string: initialText)
|
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Polls
|
|
||||||
|
|
||||||
func resetPollDefaults() {
|
|
||||||
pollOptions = ["", ""]
|
|
||||||
pollDuration = .oneDay
|
|
||||||
pollVotingFrequency = .oneVote
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getPollOptionsForAPI() -> [String]? {
|
|
||||||
let options = pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
||||||
return options.isEmpty ? nil : options
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Embeds
|
|
||||||
|
|
||||||
private func checkEmbed() {
|
|
||||||
if let url = embeddedStatusURL,
|
|
||||||
!statusText.string.contains(url.absoluteString)
|
|
||||||
{
|
|
||||||
embeddedStatus = nil
|
|
||||||
mode = .new(visibility: visibility)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Autocomplete
|
|
||||||
|
|
||||||
private func loadAutoCompleteResults(query: String) {
|
|
||||||
guard let client else { return }
|
|
||||||
var query = query
|
|
||||||
suggestedTask?.cancel()
|
|
||||||
suggestedTask = Task {
|
|
||||||
do {
|
|
||||||
var results: SearchResults?
|
|
||||||
switch query.first {
|
|
||||||
case "#":
|
|
||||||
if query.utf8.count == 1 {
|
|
||||||
withAnimation {
|
|
||||||
showRecentsTagsInline = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showRecentsTagsInline = false
|
|
||||||
query.removeFirst()
|
|
||||||
results = try await client.get(endpoint: Search.search(query: query,
|
|
||||||
type: "hashtags",
|
|
||||||
offset: 0,
|
|
||||||
following: nil),
|
|
||||||
forceVersion: .v2)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
withAnimation {
|
|
||||||
tagsSuggestions = results?.hashtags.sorted(by: { $0.totalUses > $1.totalUses }) ?? []
|
|
||||||
}
|
|
||||||
case "@":
|
|
||||||
guard query.utf8.count > 1 else { return }
|
|
||||||
query.removeFirst()
|
|
||||||
let accounts: [Account] = try await client.get(endpoint: Search.accountsSearch(query: query,
|
|
||||||
type: nil,
|
|
||||||
offset: 0,
|
|
||||||
following: nil),
|
|
||||||
forceVersion: .v1)
|
|
||||||
guard !Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
withAnimation {
|
|
||||||
mentionsSuggestions = accounts
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetAutoCompletion() {
|
|
||||||
withAnimation {
|
|
||||||
tagsSuggestions = []
|
|
||||||
mentionsSuggestions = []
|
|
||||||
currentSuggestionRange = nil
|
|
||||||
showRecentsTagsInline = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectMentionSuggestion(account: Account) {
|
|
||||||
if let range = currentSuggestionRange {
|
|
||||||
replaceTextWith(text: "@\(account.acct) ", inRange: range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectHashtagSuggestion(tag: String) {
|
|
||||||
if let range = currentSuggestionRange {
|
|
||||||
replaceTextWith(text: "#\(tag) ", inRange: range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - OpenAI Prompt
|
|
||||||
|
|
||||||
func runOpenAI(prompt: OpenAIClient.Prompt) async {
|
|
||||||
do {
|
|
||||||
let client = OpenAIClient()
|
|
||||||
let response = try await client.request(prompt)
|
|
||||||
backupStatusText = statusText
|
|
||||||
replaceTextWith(text: response.trimmedText)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Media related function
|
|
||||||
|
|
||||||
private func indexOf(container: StatusEditorMediaContainer) -> Int? {
|
|
||||||
mediaContainers.firstIndex(where: { $0.id == container.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareToPost(for pickerItem: PhotosPickerItem) {
|
|
||||||
Task(priority: .high) {
|
|
||||||
if let container = await makeMediaContainer(from: pickerItem) {
|
|
||||||
self.mediaContainers.append(container)
|
|
||||||
await upload(container: container)
|
|
||||||
self.isMediasLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareToPost(for container: StatusEditorMediaContainer) {
|
|
||||||
Task(priority: .high) {
|
|
||||||
self.mediaContainers.append(container)
|
|
||||||
await upload(container: container)
|
|
||||||
self.isMediasLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
|
|
||||||
await withTaskGroup(of: StatusEditorMediaContainer?.self, returning: StatusEditorMediaContainer?.self) { taskGroup in
|
|
||||||
taskGroup.addTask(priority: .high) { await Self.makeImageContainer(from: pickerItem) }
|
|
||||||
taskGroup.addTask(priority: .high) { await Self.makeGifContainer(from: pickerItem) }
|
|
||||||
taskGroup.addTask(priority: .high) { await Self.makeMovieContainer(from: pickerItem) }
|
|
||||||
|
|
||||||
for await container in taskGroup {
|
|
||||||
if let container {
|
|
||||||
taskGroup.cancelAll()
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeGifContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
|
|
||||||
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
|
|
||||||
|
|
||||||
return StatusEditorMediaContainer(
|
|
||||||
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: gifFile,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
|
|
||||||
guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTranseferable.self) else { return nil }
|
|
||||||
|
|
||||||
return StatusEditorMediaContainer(
|
|
||||||
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: movieFile,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> StatusEditorMediaContainer? {
|
|
||||||
guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
|
|
||||||
|
|
||||||
let compressor = StatusEditorCompressor()
|
|
||||||
|
|
||||||
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
|
|
||||||
let image = UIImage(data: compressedData)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return StatusEditorMediaContainer(
|
|
||||||
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
|
||||||
image: image,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func upload(container: StatusEditorMediaContainer) async {
|
|
||||||
if let index = indexOf(container: container) {
|
|
||||||
let originalContainer = mediaContainers[index]
|
|
||||||
guard originalContainer.mediaAttachment == nil else { return }
|
|
||||||
let newContainer = StatusEditorMediaContainer(
|
|
||||||
id: originalContainer.id,
|
|
||||||
image: originalContainer.image,
|
|
||||||
movieTransferable: originalContainer.movieTransferable,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
mediaContainers[index] = newContainer
|
|
||||||
do {
|
|
||||||
let compressor = StatusEditorCompressor()
|
|
||||||
if let image = originalContainer.image {
|
|
||||||
let imageData = try await compressor.compressImageForUpload(image)
|
|
||||||
let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg")
|
|
||||||
if let index = indexOf(container: newContainer) {
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: originalContainer.id,
|
|
||||||
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: uploadedMedia,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if let uploadedMedia, uploadedMedia.url == nil {
|
|
||||||
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
||||||
}
|
|
||||||
} else if let videoURL = originalContainer.movieTransferable?.url,
|
|
||||||
let compressedVideoURL = await compressor.compressVideo(videoURL),
|
|
||||||
let data = try? Data(contentsOf: compressedVideoURL)
|
|
||||||
{
|
|
||||||
let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType())
|
|
||||||
if let index = indexOf(container: newContainer) {
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: originalContainer.id,
|
|
||||||
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
||||||
movieTransferable: originalContainer.movieTransferable,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: uploadedMedia,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if let uploadedMedia, uploadedMedia.url == nil {
|
|
||||||
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
||||||
}
|
|
||||||
} else if let gifData = originalContainer.gifTransferable?.data {
|
|
||||||
let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif")
|
|
||||||
if let index = indexOf(container: newContainer) {
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: originalContainer.id,
|
|
||||||
image: mode.isInShareExtension ? originalContainer.image : nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: originalContainer.gifTransferable,
|
|
||||||
mediaAttachment: uploadedMedia,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if let uploadedMedia, uploadedMedia.url == nil {
|
|
||||||
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if let index = indexOf(container: newContainer) {
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: originalContainer.id,
|
|
||||||
image: originalContainer.image,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: nil,
|
|
||||||
error: error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
|
|
||||||
Task {
|
|
||||||
repeat {
|
|
||||||
if let client,
|
|
||||||
let index = mediaContainers.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
|
|
||||||
{
|
|
||||||
guard mediaContainers[index].mediaAttachment?.url == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
|
|
||||||
json: nil))
|
|
||||||
if newAttachement.url != nil {
|
|
||||||
let oldContainer = mediaContainers[index]
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: mediaAttachement.id,
|
|
||||||
image: oldContainer.image,
|
|
||||||
movieTransferable: oldContainer.movieTransferable,
|
|
||||||
gifTransferable: oldContainer.gifTransferable,
|
|
||||||
mediaAttachment: newAttachement,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try? await Task.sleep(for: .seconds(5))
|
|
||||||
} while !Task.isCancelled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addDescription(container: StatusEditorMediaContainer, description: String) async {
|
|
||||||
guard let client, let attachment = container.mediaAttachment else { return }
|
|
||||||
if let index = indexOf(container: container) {
|
|
||||||
do {
|
|
||||||
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
|
|
||||||
json: .init(description: description)))
|
|
||||||
mediaContainers[index] = StatusEditorMediaContainer(
|
|
||||||
id: container.id,
|
|
||||||
image: nil,
|
|
||||||
movieTransferable: nil,
|
|
||||||
gifTransferable: nil,
|
|
||||||
mediaAttachment: media,
|
|
||||||
error: nil
|
|
||||||
)
|
|
||||||
} catch { print(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mediaAttributes: [StatusData.MediaAttribute] = []
|
|
||||||
func editDescription(container: StatusEditorMediaContainer, description: String) async {
|
|
||||||
guard let attachment = container.mediaAttachment else { return }
|
|
||||||
if indexOf(container: container) != nil {
|
|
||||||
mediaAttributes.append(StatusData.MediaAttribute(id: attachment.id, description: description, thumbnail: nil, focus: nil))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
|
|
||||||
guard let client else { return nil }
|
|
||||||
return try await client.mediaUpload(endpoint: Media.medias,
|
|
||||||
version: .v2,
|
|
||||||
method: "POST",
|
|
||||||
mimeType: mimeType,
|
|
||||||
filename: "file",
|
|
||||||
data: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Custom emojis
|
|
||||||
|
|
||||||
func fetchCustomEmojis() async {
|
|
||||||
typealias EmojiContainer = StatusEditorCategorizedEmojiContainer
|
|
||||||
|
|
||||||
guard let client else { return }
|
|
||||||
do {
|
|
||||||
let customEmojis: [Emoji] = try await client.get(endpoint: CustomEmojis.customEmojis) ?? []
|
|
||||||
var emojiContainers: [EmojiContainer] = []
|
|
||||||
|
|
||||||
customEmojis.reduce([String: [Emoji]]()) { currentDict, emoji in
|
|
||||||
var dict = currentDict
|
|
||||||
let category = emoji.category ?? "Uncategorized"
|
|
||||||
|
|
||||||
if let emojis = dict[category] {
|
|
||||||
dict[category] = emojis + [emoji]
|
|
||||||
} else {
|
|
||||||
dict[category] = [emoji]
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict
|
|
||||||
}.sorted(by: { lhs, rhs in
|
|
||||||
if rhs.key == "Uncategorized" { false }
|
|
||||||
else if lhs.key == "Uncategorized" { true }
|
|
||||||
else { lhs.key < rhs.key }
|
|
||||||
}).forEach { key, value in
|
|
||||||
emojiContainers.append(.init(categoryName: key, emojis: value))
|
|
||||||
}
|
|
||||||
|
|
||||||
customEmojiContainer = emojiContainers
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DropDelegate
|
|
||||||
|
|
||||||
extension StatusEditorViewModel: DropDelegate {
|
|
||||||
public func performDrop(info: DropInfo) -> Bool {
|
|
||||||
let item = info.itemProviders(for: StatusEditorUTTypeSupported.types())
|
|
||||||
processItemsProvider(items: item)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UITextPasteDelegate
|
|
||||||
|
|
||||||
extension StatusEditorViewModel: UITextPasteDelegate {
|
|
||||||
public func textPasteConfigurationSupporting(
|
|
||||||
_: UITextPasteConfigurationSupporting,
|
|
||||||
transform item: UITextPasteItem
|
|
||||||
) {
|
|
||||||
if !item.itemProvider.registeredContentTypes(conformingTo: .image).isEmpty ||
|
|
||||||
!item.itemProvider.registeredContentTypes(conformingTo: .video).isEmpty ||
|
|
||||||
!item.itemProvider.registeredContentTypes(conformingTo: .gif).isEmpty
|
|
||||||
{
|
|
||||||
processItemsProvider(items: [item.itemProvider])
|
|
||||||
item.setNoResult()
|
|
||||||
} else {
|
|
||||||
item.setDefaultResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PhotosPickerItem: @unchecked Sendable {}
|
|
141
Packages/Status/Sources/Status/Editor/ToolbarItems.swift
Normal file
141
Packages/Status/Sources/Status/Editor/ToolbarItems.swift
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
import Env
|
||||||
|
import Models
|
||||||
|
import StoreKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
@MainActor
|
||||||
|
struct ToolbarItems: ToolbarContent {
|
||||||
|
@State private var isLanguageConfirmPresented = false
|
||||||
|
@State private var isDismissAlertPresented: Bool = false
|
||||||
|
let mainSEVM: ViewModel
|
||||||
|
let followUpSEVMs: [ViewModel]
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(UserPreferences.self) private var preferences
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@Environment(\.dismissWindow) private var dismissWindow
|
||||||
|
#else
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
mainSEVM.evaluateLanguages()
|
||||||
|
if preferences.autoDetectPostLanguage, let _ = mainSEVM.languageConfirmationDialogLanguages {
|
||||||
|
isLanguageConfirmPresented = true
|
||||||
|
} else {
|
||||||
|
await postAllStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if mainSEVM.isPosting {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("status.action.post").bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!mainSEVM.canPost)
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
.confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: {
|
||||||
|
languageConfirmationDialog
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
if mainSEVM.shouldDisplayDismissWarning {
|
||||||
|
isDismissAlertPresented = true
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
NotificationCenter.default.post(name: .shareSheetClose,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("action.cancel")
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.confirmationDialog(
|
||||||
|
"",
|
||||||
|
isPresented: $isDismissAlertPresented,
|
||||||
|
actions: {
|
||||||
|
Button("status.draft.delete", role: .destructive) {
|
||||||
|
close()
|
||||||
|
NotificationCenter.default.post(name: .shareSheetClose,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
Button("status.draft.save") {
|
||||||
|
context.insert(Draft(content: mainSEVM.statusText.string))
|
||||||
|
close()
|
||||||
|
NotificationCenter.default.post(name: .shareSheetClose,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
Button("action.cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func postStatus(with model: ViewModel, isMainPost: Bool) async -> Status? {
|
||||||
|
let status = await model.postStatus()
|
||||||
|
|
||||||
|
if status != nil, isMainPost {
|
||||||
|
close()
|
||||||
|
SoundEffectManager.shared.playSound(.tootSent)
|
||||||
|
NotificationCenter.default.post(name: .shareSheetClose, object: nil)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
if !mainSEVM.mode.isInShareExtension, !preferences.requestedReview {
|
||||||
|
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||||
|
SKStoreReviewController.requestReview(in: scene)
|
||||||
|
}
|
||||||
|
preferences.requestedReview = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postAllStatus() async {
|
||||||
|
guard var latestPost = await postStatus(with: mainSEVM, isMainPost: true) else { return }
|
||||||
|
for p in followUpSEVMs {
|
||||||
|
p.mode = .replyTo(status: latestPost)
|
||||||
|
guard let post = await postStatus(with: p, isMainPost: false) else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
latestPost = post
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private func close() { dismissWindow() }
|
||||||
|
#else
|
||||||
|
private func close() { dismiss() }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var languageConfirmationDialog: some View {
|
||||||
|
if let (detected: detected, selected: selected) = mainSEVM.languageConfirmationDialogLanguages,
|
||||||
|
let detectedLong = Locale.current.localizedString(forLanguageCode: detected),
|
||||||
|
let selectedLong = Locale.current.localizedString(forLanguageCode: selected)
|
||||||
|
{
|
||||||
|
Button("status.editor.language-select.confirmation.detected-\(detectedLong)") {
|
||||||
|
mainSEVM.selectedLanguage = detected
|
||||||
|
Task { await postAllStatus() }
|
||||||
|
}
|
||||||
|
Button("status.editor.language-select.confirmation.selected-\(selectedLong)") {
|
||||||
|
mainSEVM.selectedLanguage = selected
|
||||||
|
Task { await postAllStatus() }
|
||||||
|
}
|
||||||
|
Button("action.cancel", role: .cancel) {
|
||||||
|
mainSEVM.languageConfirmationDialogLanguages = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import Models
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public extension StatusEditorViewModel {
|
public extension StatusEditor.ViewModel {
|
||||||
enum Mode {
|
enum Mode {
|
||||||
case replyTo(status: Status)
|
case replyTo(status: Status)
|
||||||
case new(visibility: Models.Visibility)
|
case new(visibility: Models.Visibility)
|
897
Packages/Status/Sources/Status/Editor/ViewModel.swift
Normal file
897
Packages/Status/Sources/Status/Editor/ViewModel.swift
Normal file
|
@ -0,0 +1,897 @@
|
||||||
|
import Combine
|
||||||
|
import DesignSystem
|
||||||
|
import Env
|
||||||
|
import Models
|
||||||
|
import NaturalLanguage
|
||||||
|
import Network
|
||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension StatusEditor {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable public class ViewModel: NSObject, Identifiable {
|
||||||
|
public let id = UUID()
|
||||||
|
|
||||||
|
var mode: Mode
|
||||||
|
|
||||||
|
var client: Client?
|
||||||
|
var currentAccount: Account? {
|
||||||
|
didSet {
|
||||||
|
if let itemsProvider {
|
||||||
|
mediaContainers = []
|
||||||
|
processItemsProvider(items: itemsProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var theme: Theme?
|
||||||
|
var preferences: UserPreferences?
|
||||||
|
var languageConfirmationDialogLanguages: (detected: String, selected: String)?
|
||||||
|
|
||||||
|
var textView: UITextView? {
|
||||||
|
didSet {
|
||||||
|
textView?.pasteDelegate = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedRange: NSRange {
|
||||||
|
get {
|
||||||
|
guard let textView else {
|
||||||
|
return .init(location: 0, length: 0)
|
||||||
|
}
|
||||||
|
return textView.selectedRange
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
textView?.selectedRange = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var markedTextRange: UITextRange? {
|
||||||
|
guard let textView else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return textView.markedTextRange
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusText = NSMutableAttributedString(string: "") {
|
||||||
|
didSet {
|
||||||
|
let range = selectedRange
|
||||||
|
processText()
|
||||||
|
checkEmbed()
|
||||||
|
textView?.attributedText = statusText
|
||||||
|
selectedRange = range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var urlLengthAdjustments: Int = 0
|
||||||
|
private let maxLengthOfUrl = 23
|
||||||
|
|
||||||
|
private var spoilerTextCount: Int {
|
||||||
|
spoilerOn ? spoilerText.utf16.count : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusTextCharacterLength: Int {
|
||||||
|
urlLengthAdjustments - statusText.string.utf16.count - spoilerTextCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private var itemsProvider: [NSItemProvider]?
|
||||||
|
|
||||||
|
var backupStatusText: NSAttributedString?
|
||||||
|
|
||||||
|
var showPoll: Bool = false
|
||||||
|
var pollVotingFrequency = PollVotingFrequency.oneVote
|
||||||
|
var pollDuration = Duration.oneDay
|
||||||
|
var pollOptions: [String] = ["", ""]
|
||||||
|
|
||||||
|
var spoilerOn: Bool = false
|
||||||
|
var spoilerText: String = ""
|
||||||
|
|
||||||
|
var isPosting: Bool = false
|
||||||
|
var mediaPickers: [PhotosPickerItem] = [] {
|
||||||
|
didSet {
|
||||||
|
if mediaPickers.count > 4 {
|
||||||
|
mediaPickers = mediaPickers.prefix(4).map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedIDs = oldValue
|
||||||
|
.filter { !mediaPickers.contains($0) }
|
||||||
|
.compactMap(\.itemIdentifier)
|
||||||
|
mediaContainers.removeAll { removedIDs.contains($0.id) }
|
||||||
|
|
||||||
|
let newPickerItems = mediaPickers.filter { !oldValue.contains($0) }
|
||||||
|
if !newPickerItems.isEmpty {
|
||||||
|
isMediasLoading = true
|
||||||
|
for item in newPickerItems {
|
||||||
|
prepareToPost(for: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMediasLoading: Bool = false
|
||||||
|
|
||||||
|
var mediaContainers: [MediaContainer] = []
|
||||||
|
var replyToStatus: Status?
|
||||||
|
var embeddedStatus: Status?
|
||||||
|
|
||||||
|
var customEmojiContainer: [CategorizedEmojiContainer] = []
|
||||||
|
|
||||||
|
var postingError: String?
|
||||||
|
var showPostingErrorAlert: Bool = false
|
||||||
|
|
||||||
|
var canPost: Bool {
|
||||||
|
statusText.length > 0 || !mediaContainers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldDisablePollButton: Bool {
|
||||||
|
!mediaPickers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldDisplayDismissWarning: Bool {
|
||||||
|
var modifiedStatusText = statusText.string.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if let mentionString, modifiedStatusText.hasPrefix(mentionString) {
|
||||||
|
modifiedStatusText = String(modifiedStatusText.dropFirst(mentionString.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
return !modifiedStatusText.isEmpty && !mode.isInShareExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibility: Models.Visibility = .pub
|
||||||
|
|
||||||
|
var mentionsSuggestions: [Account] = []
|
||||||
|
var tagsSuggestions: [Tag] = []
|
||||||
|
var showRecentsTagsInline: Bool = false
|
||||||
|
var selectedLanguage: String?
|
||||||
|
var hasExplicitlySelectedLanguage: Bool = false
|
||||||
|
private var currentSuggestionRange: NSRange?
|
||||||
|
|
||||||
|
private var embeddedStatusURL: URL? {
|
||||||
|
URL(string: embeddedStatus?.reblog?.url ?? embeddedStatus?.url ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mentionString: String?
|
||||||
|
|
||||||
|
private var suggestedTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(mode: Mode) {
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInitialLanguageSelection(preference: String?) {
|
||||||
|
switch mode {
|
||||||
|
case let .edit(status), let .quote(status):
|
||||||
|
selectedLanguage = status.language
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLanguage = selectedLanguage ?? preference ?? currentAccount?.source?.language
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateLanguages() {
|
||||||
|
if let detectedLang = detectLanguage(text: statusText.string),
|
||||||
|
let selectedLanguage,
|
||||||
|
selectedLanguage != "",
|
||||||
|
selectedLanguage != detectedLang
|
||||||
|
{
|
||||||
|
languageConfirmationDialogLanguages = (detected: detectedLang, selected: selectedLanguage)
|
||||||
|
} else {
|
||||||
|
languageConfirmationDialogLanguages = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postStatus() async -> Status? {
|
||||||
|
guard let client else { return nil }
|
||||||
|
do {
|
||||||
|
isPosting = true
|
||||||
|
let postStatus: Status?
|
||||||
|
var pollData: StatusData.PollData?
|
||||||
|
if let pollOptions = getPollOptionsForAPI() {
|
||||||
|
pollData = .init(options: pollOptions,
|
||||||
|
multiple: pollVotingFrequency.canVoteMultipleTimes,
|
||||||
|
expires_in: pollDuration.rawValue)
|
||||||
|
}
|
||||||
|
let data = StatusData(status: statusText.string,
|
||||||
|
visibility: visibility,
|
||||||
|
inReplyToId: mode.replyToStatus?.id,
|
||||||
|
spoilerText: spoilerOn ? spoilerText : nil,
|
||||||
|
mediaIds: mediaContainers.compactMap { $0.mediaAttachment?.id },
|
||||||
|
poll: pollData,
|
||||||
|
language: selectedLanguage,
|
||||||
|
mediaAttributes: mediaAttributes)
|
||||||
|
switch mode {
|
||||||
|
case .new, .replyTo, .quote, .mention, .shareExtension:
|
||||||
|
postStatus = try await client.post(endpoint: Statuses.postStatus(json: data))
|
||||||
|
if let postStatus {
|
||||||
|
StreamWatcher.shared.emmitPostEvent(for: postStatus)
|
||||||
|
}
|
||||||
|
case let .edit(status):
|
||||||
|
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
|
||||||
|
if let postStatus {
|
||||||
|
StreamWatcher.shared.emmitEditEvent(for: postStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HapticManager.shared.fireHaptic(.notification(.success))
|
||||||
|
if hasExplicitlySelectedLanguage, let selectedLanguage {
|
||||||
|
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
|
||||||
|
}
|
||||||
|
isPosting = false
|
||||||
|
return postStatus
|
||||||
|
} catch {
|
||||||
|
if let error = error as? Models.ServerError {
|
||||||
|
postingError = error.error
|
||||||
|
showPostingErrorAlert = true
|
||||||
|
}
|
||||||
|
isPosting = false
|
||||||
|
HapticManager.shared.fireHaptic(.notification(.error))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Text manipulations
|
||||||
|
|
||||||
|
func insertStatusText(text: String) {
|
||||||
|
let string = statusText
|
||||||
|
string.mutableString.insert(text, at: selectedRange.location)
|
||||||
|
statusText = string
|
||||||
|
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
|
||||||
|
processText()
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTextWith(text: String, inRange: NSRange) {
|
||||||
|
let string = statusText
|
||||||
|
string.mutableString.deleteCharacters(in: inRange)
|
||||||
|
string.mutableString.insert(text, at: inRange.location)
|
||||||
|
statusText = string
|
||||||
|
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTextWith(text: String) {
|
||||||
|
statusText = .init(string: text)
|
||||||
|
selectedRange = .init(location: text.utf16.count, length: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareStatusText() {
|
||||||
|
switch mode {
|
||||||
|
case let .new(visibility):
|
||||||
|
self.visibility = visibility
|
||||||
|
case let .shareExtension(items):
|
||||||
|
itemsProvider = items
|
||||||
|
visibility = .pub
|
||||||
|
processItemsProvider(items: items)
|
||||||
|
case let .replyTo(status):
|
||||||
|
var mentionString = ""
|
||||||
|
if (status.reblog?.account.acct ?? status.account.acct) != currentAccount?.acct {
|
||||||
|
mentionString = "@\(status.reblog?.account.acct ?? status.account.acct)"
|
||||||
|
}
|
||||||
|
for mention in status.mentions where mention.acct != currentAccount?.acct {
|
||||||
|
if !mentionString.isEmpty {
|
||||||
|
mentionString += " "
|
||||||
|
}
|
||||||
|
mentionString += "@\(mention.acct)"
|
||||||
|
}
|
||||||
|
if !mentionString.isEmpty {
|
||||||
|
mentionString += " "
|
||||||
|
}
|
||||||
|
replyToStatus = status
|
||||||
|
visibility = UserPreferences.shared.getReplyVisibility(of: status)
|
||||||
|
statusText = .init(string: mentionString)
|
||||||
|
selectedRange = .init(location: mentionString.utf16.count, length: 0)
|
||||||
|
if !mentionString.isEmpty {
|
||||||
|
self.mentionString = mentionString.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
if !status.spoilerText.asRawText.isEmpty {
|
||||||
|
spoilerOn = true
|
||||||
|
spoilerText = status.spoilerText.asRawText
|
||||||
|
}
|
||||||
|
case let .mention(account, visibility):
|
||||||
|
statusText = .init(string: "@\(account.acct) ")
|
||||||
|
self.visibility = visibility
|
||||||
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
|
case let .edit(status):
|
||||||
|
var rawText = status.content.asRawText.escape()
|
||||||
|
for mention in status.mentions {
|
||||||
|
rawText = rawText.replacingOccurrences(of: "@\(mention.username)", with: "@\(mention.acct)")
|
||||||
|
}
|
||||||
|
statusText = .init(string: rawText)
|
||||||
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
|
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
||||||
|
spoilerText = status.spoilerText.asRawText
|
||||||
|
visibility = status.visibility
|
||||||
|
mediaContainers = status.mediaAttachments.map {
|
||||||
|
MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: $0,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case let .quote(status):
|
||||||
|
embeddedStatus = status
|
||||||
|
if let url = embeddedStatusURL {
|
||||||
|
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
||||||
|
selectedRange = .init(location: 0, length: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processText() {
|
||||||
|
guard markedTextRange == nil else { return }
|
||||||
|
statusText.addAttributes([.foregroundColor: UIColor(Theme.shared.labelColor),
|
||||||
|
.font: Font.scaledBodyUIFont,
|
||||||
|
.backgroundColor: UIColor.clear,
|
||||||
|
.underlineColor: UIColor.clear],
|
||||||
|
range: NSMakeRange(0, statusText.string.utf16.count))
|
||||||
|
let hashtagPattern = "(#+[\\w0-9(_)]{0,})"
|
||||||
|
let mentionPattern = "(@+[a-zA-Z0-9(_).-]{1,})"
|
||||||
|
let urlPattern = "(?i)https?://(?:www\\.)?\\S+(?:/|\\b)"
|
||||||
|
|
||||||
|
do {
|
||||||
|
let hashtagRegex = try NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
|
let mentionRegex = try NSRegularExpression(pattern: mentionPattern, options: [])
|
||||||
|
let urlRegex = try NSRegularExpression(pattern: urlPattern, options: [])
|
||||||
|
|
||||||
|
let range = NSMakeRange(0, statusText.string.utf16.count)
|
||||||
|
var ranges = hashtagRegex.matches(in: statusText.string,
|
||||||
|
options: [],
|
||||||
|
range: range).map(\.range)
|
||||||
|
ranges.append(contentsOf: mentionRegex.matches(in: statusText.string,
|
||||||
|
options: [],
|
||||||
|
range: range).map(\.range))
|
||||||
|
|
||||||
|
let urlRanges = urlRegex.matches(in: statusText.string,
|
||||||
|
options: [],
|
||||||
|
range: range).map(\.range)
|
||||||
|
|
||||||
|
var foundSuggestionRange = false
|
||||||
|
for nsRange in ranges {
|
||||||
|
statusText.addAttributes([.foregroundColor: UIColor(theme?.tintColor ?? .brand)],
|
||||||
|
range: nsRange)
|
||||||
|
if selectedRange.location == (nsRange.location + nsRange.length),
|
||||||
|
let range = Range(nsRange, in: statusText.string)
|
||||||
|
{
|
||||||
|
foundSuggestionRange = true
|
||||||
|
currentSuggestionRange = nsRange
|
||||||
|
loadAutoCompleteResults(query: String(statusText.string[range]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundSuggestionRange || ranges.isEmpty {
|
||||||
|
resetAutoCompletion()
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUrlLength = 0
|
||||||
|
var numUrls = 0
|
||||||
|
|
||||||
|
for range in urlRanges {
|
||||||
|
numUrls += 1
|
||||||
|
totalUrlLength += range.length
|
||||||
|
|
||||||
|
statusText.addAttributes([.foregroundColor: UIColor(theme?.tintColor ?? .brand),
|
||||||
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||||
|
.underlineColor: UIColor(theme?.tintColor ?? .brand)],
|
||||||
|
range: NSRange(location: range.location, length: range.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
urlLengthAdjustments = totalUrlLength - (maxLengthOfUrl * numUrls)
|
||||||
|
|
||||||
|
statusText.enumerateAttributes(in: range) { attributes, range, _ in
|
||||||
|
if attributes[.link] != nil {
|
||||||
|
statusText.removeAttribute(.link, range: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shar sheet / Item provider
|
||||||
|
|
||||||
|
func processURLs(urls: [URL]) {
|
||||||
|
isMediasLoading = true
|
||||||
|
let items = urls.filter { $0.startAccessingSecurityScopedResource() }
|
||||||
|
.compactMap { NSItemProvider(contentsOf: $0) }
|
||||||
|
processItemsProvider(items: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processGIFData(data: Data) {
|
||||||
|
isMediasLoading = true
|
||||||
|
let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).gif")
|
||||||
|
try? data.write(to: url)
|
||||||
|
let container = MediaContainer(id: UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: .init(url: url),
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCameraPhoto(image: UIImage) {
|
||||||
|
let container = MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: image,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processItemsProvider(items: [NSItemProvider]) {
|
||||||
|
Task {
|
||||||
|
var initialText: String = ""
|
||||||
|
for item in items {
|
||||||
|
if let identifier = item.registeredTypeIdentifiers.first,
|
||||||
|
let handledItemType = UTTypeSupported(rawValue: identifier)
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
let compressor = Compressor()
|
||||||
|
let content = try await handledItemType.loadItemContent(item: item)
|
||||||
|
if let text = content as? String {
|
||||||
|
initialText += "\(text) "
|
||||||
|
} else if let image = content as? UIImage {
|
||||||
|
let container = MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: image,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
} else if let content = content as? ImageFileTranseferable,
|
||||||
|
let compressedData = await compressor.compressImageFrom(url: content.url),
|
||||||
|
let image = UIImage(data: compressedData)
|
||||||
|
{
|
||||||
|
let container = MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: image,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
} else if let video = content as? MovieFileTranseferable {
|
||||||
|
let container = MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: video,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
} else if let gif = content as? GifFileTranseferable {
|
||||||
|
let container = MediaContainer(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: gif,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
prepareToPost(for: container)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
isMediasLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !initialText.isEmpty {
|
||||||
|
statusText = .init(string: initialText)
|
||||||
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Polls
|
||||||
|
|
||||||
|
func resetPollDefaults() {
|
||||||
|
pollOptions = ["", ""]
|
||||||
|
pollDuration = .oneDay
|
||||||
|
pollVotingFrequency = .oneVote
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getPollOptionsForAPI() -> [String]? {
|
||||||
|
let options = pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
return options.isEmpty ? nil : options
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Embeds
|
||||||
|
|
||||||
|
private func checkEmbed() {
|
||||||
|
if let url = embeddedStatusURL,
|
||||||
|
!statusText.string.contains(url.absoluteString)
|
||||||
|
{
|
||||||
|
embeddedStatus = nil
|
||||||
|
mode = .new(visibility: visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Autocomplete
|
||||||
|
|
||||||
|
private func loadAutoCompleteResults(query: String) {
|
||||||
|
guard let client else { return }
|
||||||
|
var query = query
|
||||||
|
suggestedTask?.cancel()
|
||||||
|
suggestedTask = Task {
|
||||||
|
do {
|
||||||
|
var results: SearchResults?
|
||||||
|
switch query.first {
|
||||||
|
case "#":
|
||||||
|
if query.utf8.count == 1 {
|
||||||
|
withAnimation {
|
||||||
|
showRecentsTagsInline = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showRecentsTagsInline = false
|
||||||
|
query.removeFirst()
|
||||||
|
results = try await client.get(endpoint: Search.search(query: query,
|
||||||
|
type: "hashtags",
|
||||||
|
offset: 0,
|
||||||
|
following: nil),
|
||||||
|
forceVersion: .v2)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withAnimation {
|
||||||
|
tagsSuggestions = results?.hashtags.sorted(by: { $0.totalUses > $1.totalUses }) ?? []
|
||||||
|
}
|
||||||
|
case "@":
|
||||||
|
guard query.utf8.count > 1 else { return }
|
||||||
|
query.removeFirst()
|
||||||
|
let accounts: [Account] = try await client.get(endpoint: Search.accountsSearch(query: query,
|
||||||
|
type: nil,
|
||||||
|
offset: 0,
|
||||||
|
following: nil),
|
||||||
|
forceVersion: .v1)
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withAnimation {
|
||||||
|
mentionsSuggestions = accounts
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetAutoCompletion() {
|
||||||
|
withAnimation {
|
||||||
|
tagsSuggestions = []
|
||||||
|
mentionsSuggestions = []
|
||||||
|
currentSuggestionRange = nil
|
||||||
|
showRecentsTagsInline = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMentionSuggestion(account: Account) {
|
||||||
|
if let range = currentSuggestionRange {
|
||||||
|
replaceTextWith(text: "@\(account.acct) ", inRange: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectHashtagSuggestion(tag: String) {
|
||||||
|
if let range = currentSuggestionRange {
|
||||||
|
replaceTextWith(text: "#\(tag) ", inRange: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OpenAI Prompt
|
||||||
|
|
||||||
|
func runOpenAI(prompt: OpenAIClient.Prompt) async {
|
||||||
|
do {
|
||||||
|
let client = OpenAIClient()
|
||||||
|
let response = try await client.request(prompt)
|
||||||
|
backupStatusText = statusText
|
||||||
|
replaceTextWith(text: response.trimmedText)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Media related function
|
||||||
|
|
||||||
|
private func indexOf(container: MediaContainer) -> Int? {
|
||||||
|
mediaContainers.firstIndex(where: { $0.id == container.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareToPost(for pickerItem: PhotosPickerItem) {
|
||||||
|
Task(priority: .high) {
|
||||||
|
if let container = await makeMediaContainer(from: pickerItem) {
|
||||||
|
self.mediaContainers.append(container)
|
||||||
|
await upload(container: container)
|
||||||
|
self.isMediasLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareToPost(for container: MediaContainer) {
|
||||||
|
Task(priority: .high) {
|
||||||
|
self.mediaContainers.append(container)
|
||||||
|
await upload(container: container)
|
||||||
|
self.isMediasLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeMediaContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
||||||
|
await withTaskGroup(of: MediaContainer?.self, returning: MediaContainer?.self) { taskGroup in
|
||||||
|
taskGroup.addTask(priority: .high) { await Self.makeImageContainer(from: pickerItem) }
|
||||||
|
taskGroup.addTask(priority: .high) { await Self.makeGifContainer(from: pickerItem) }
|
||||||
|
taskGroup.addTask(priority: .high) { await Self.makeMovieContainer(from: pickerItem) }
|
||||||
|
|
||||||
|
for await container in taskGroup {
|
||||||
|
if let container {
|
||||||
|
taskGroup.cancelAll()
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeGifContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
||||||
|
guard let gifFile = try? await pickerItem.loadTransferable(type: GifFileTranseferable.self) else { return nil }
|
||||||
|
|
||||||
|
return MediaContainer(
|
||||||
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: gifFile,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeMovieContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
||||||
|
guard let movieFile = try? await pickerItem.loadTransferable(type: MovieFileTranseferable.self) else { return nil }
|
||||||
|
|
||||||
|
return MediaContainer(
|
||||||
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: movieFile,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeImageContainer(from pickerItem: PhotosPickerItem) async -> MediaContainer? {
|
||||||
|
guard let imageFile = try? await pickerItem.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
|
||||||
|
|
||||||
|
let compressor = Compressor()
|
||||||
|
|
||||||
|
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
|
||||||
|
let image = UIImage(data: compressedData)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return MediaContainer(
|
||||||
|
id: pickerItem.itemIdentifier ?? UUID().uuidString,
|
||||||
|
image: image,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(container: MediaContainer) async {
|
||||||
|
if let index = indexOf(container: container) {
|
||||||
|
let originalContainer = mediaContainers[index]
|
||||||
|
guard originalContainer.mediaAttachment == nil else { return }
|
||||||
|
let newContainer = MediaContainer(
|
||||||
|
id: originalContainer.id,
|
||||||
|
image: originalContainer.image,
|
||||||
|
movieTransferable: originalContainer.movieTransferable,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
mediaContainers[index] = newContainer
|
||||||
|
do {
|
||||||
|
let compressor = Compressor()
|
||||||
|
if let image = originalContainer.image {
|
||||||
|
let imageData = try await compressor.compressImageForUpload(image)
|
||||||
|
let uploadedMedia = try await uploadMedia(data: imageData, mimeType: "image/jpeg")
|
||||||
|
if let index = indexOf(container: newContainer) {
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: originalContainer.id,
|
||||||
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: uploadedMedia,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let uploadedMedia, uploadedMedia.url == nil {
|
||||||
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
||||||
|
}
|
||||||
|
} else if let videoURL = originalContainer.movieTransferable?.url,
|
||||||
|
let compressedVideoURL = await compressor.compressVideo(videoURL),
|
||||||
|
let data = try? Data(contentsOf: compressedVideoURL)
|
||||||
|
{
|
||||||
|
let uploadedMedia = try await uploadMedia(data: data, mimeType: compressedVideoURL.mimeType())
|
||||||
|
if let index = indexOf(container: newContainer) {
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: originalContainer.id,
|
||||||
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
||||||
|
movieTransferable: originalContainer.movieTransferable,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: uploadedMedia,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let uploadedMedia, uploadedMedia.url == nil {
|
||||||
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
||||||
|
}
|
||||||
|
} else if let gifData = originalContainer.gifTransferable?.data {
|
||||||
|
let uploadedMedia = try await uploadMedia(data: gifData, mimeType: "image/gif")
|
||||||
|
if let index = indexOf(container: newContainer) {
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: originalContainer.id,
|
||||||
|
image: mode.isInShareExtension ? originalContainer.image : nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: originalContainer.gifTransferable,
|
||||||
|
mediaAttachment: uploadedMedia,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let uploadedMedia, uploadedMedia.url == nil {
|
||||||
|
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if let index = indexOf(container: newContainer) {
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: originalContainer.id,
|
||||||
|
image: originalContainer.image,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: nil,
|
||||||
|
error: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
|
||||||
|
Task {
|
||||||
|
repeat {
|
||||||
|
if let client,
|
||||||
|
let index = mediaContainers.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
|
||||||
|
{
|
||||||
|
guard mediaContainers[index].mediaAttachment?.url == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
|
||||||
|
json: nil))
|
||||||
|
if newAttachement.url != nil {
|
||||||
|
let oldContainer = mediaContainers[index]
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: mediaAttachement.id,
|
||||||
|
image: oldContainer.image,
|
||||||
|
movieTransferable: oldContainer.movieTransferable,
|
||||||
|
gifTransferable: oldContainer.gifTransferable,
|
||||||
|
mediaAttachment: newAttachement,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
} while !Task.isCancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDescription(container: MediaContainer, description: String) async {
|
||||||
|
guard let client, let attachment = container.mediaAttachment else { return }
|
||||||
|
if let index = indexOf(container: container) {
|
||||||
|
do {
|
||||||
|
let media: MediaAttachment = try await client.put(endpoint: Media.media(id: attachment.id,
|
||||||
|
json: .init(description: description)))
|
||||||
|
mediaContainers[index] = MediaContainer(
|
||||||
|
id: container.id,
|
||||||
|
image: nil,
|
||||||
|
movieTransferable: nil,
|
||||||
|
gifTransferable: nil,
|
||||||
|
mediaAttachment: media,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
} catch { print(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediaAttributes: [StatusData.MediaAttribute] = []
|
||||||
|
func editDescription(container: MediaContainer, description: String) async {
|
||||||
|
guard let attachment = container.mediaAttachment else { return }
|
||||||
|
if indexOf(container: container) != nil {
|
||||||
|
mediaAttributes.append(StatusData.MediaAttribute(id: attachment.id, description: description, thumbnail: nil, focus: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadMedia(data: Data, mimeType: String) async throws -> MediaAttachment? {
|
||||||
|
guard let client else { return nil }
|
||||||
|
return try await client.mediaUpload(endpoint: Media.medias,
|
||||||
|
version: .v2,
|
||||||
|
method: "POST",
|
||||||
|
mimeType: mimeType,
|
||||||
|
filename: "file",
|
||||||
|
data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom emojis
|
||||||
|
|
||||||
|
func fetchCustomEmojis() async {
|
||||||
|
typealias EmojiContainer = CategorizedEmojiContainer
|
||||||
|
|
||||||
|
guard let client else { return }
|
||||||
|
do {
|
||||||
|
let customEmojis: [Emoji] = try await client.get(endpoint: CustomEmojis.customEmojis) ?? []
|
||||||
|
var emojiContainers: [EmojiContainer] = []
|
||||||
|
|
||||||
|
customEmojis.reduce([String: [Emoji]]()) { currentDict, emoji in
|
||||||
|
var dict = currentDict
|
||||||
|
let category = emoji.category ?? "Uncategorized"
|
||||||
|
|
||||||
|
if let emojis = dict[category] {
|
||||||
|
dict[category] = emojis + [emoji]
|
||||||
|
} else {
|
||||||
|
dict[category] = [emoji]
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}.sorted(by: { lhs, rhs in
|
||||||
|
if rhs.key == "Uncategorized" { false }
|
||||||
|
else if lhs.key == "Uncategorized" { true }
|
||||||
|
else { lhs.key < rhs.key }
|
||||||
|
}).forEach { key, value in
|
||||||
|
emojiContainers.append(.init(categoryName: key, emojis: value))
|
||||||
|
}
|
||||||
|
|
||||||
|
customEmojiContainer = emojiContainers
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DropDelegate
|
||||||
|
|
||||||
|
extension StatusEditor.ViewModel: DropDelegate {
|
||||||
|
public func performDrop(info: DropInfo) -> Bool {
|
||||||
|
let item = info.itemProviders(for: StatusEditor.UTTypeSupported.types())
|
||||||
|
processItemsProvider(items: item)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITextPasteDelegate
|
||||||
|
|
||||||
|
extension StatusEditor.ViewModel: UITextPasteDelegate {
|
||||||
|
public func textPasteConfigurationSupporting(
|
||||||
|
_: UITextPasteConfigurationSupporting,
|
||||||
|
transform item: UITextPasteItem
|
||||||
|
) {
|
||||||
|
if !item.itemProvider.registeredContentTypes(conformingTo: .image).isEmpty ||
|
||||||
|
!item.itemProvider.registeredContentTypes(conformingTo: .video).isEmpty ||
|
||||||
|
!item.itemProvider.registeredContentTypes(conformingTo: .gif).isEmpty
|
||||||
|
{
|
||||||
|
processItemsProvider(items: [item.itemProvider])
|
||||||
|
item.setNoResult()
|
||||||
|
} else {
|
||||||
|
item.setDefaultResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PhotosPickerItem: @unchecked Sendable {}
|
Loading…
Reference in a new issue