diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index de8e1cab..0a0e9f48 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -61533,6 +61533,125 @@ } } }, + "status.editor.follow-up.text.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type your follow-up content here." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type your follow-up content here." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Type your follow-up content here." + } + } + } + }, "status.editor.language-select.confirmation.detected-%@" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift index 0e076498..37716b47 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift @@ -12,8 +12,9 @@ struct StatusEditorAccessoryView: View { @Environment(CurrentInstance.self) private var currentInstance @Environment(\.colorScheme) private var colorScheme - @FocusState.Binding var isSpoilerTextFocused: Bool - var viewModel: StatusEditorViewModel + @FocusState.Binding var isSpoilerTextFocused: UUID? + let focusedSEVM: StatusEditorViewModel + @Binding var followUpSEVMs: [StatusEditorViewModel] @State private var isDraftsSheetDisplayed: Bool = false @State private var isLanguageSheetDisplayed: Bool = false @@ -25,7 +26,8 @@ struct StatusEditorAccessoryView: View { @State private var isCameraPickerPresented: Bool = false var body: some View { - @Bindable var viewModel = viewModel + @Bindable var viewModel = focusedSEVM + VStack(spacing: 0) { Divider() HStack { @@ -83,6 +85,14 @@ struct StatusEditorAccessoryView: View { .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() @@ -98,7 +108,7 @@ struct StatusEditorAccessoryView: View { withAnimation { viewModel.spoilerOn.toggle() } - isSpoilerTextFocused.toggle() + isSpoilerTextFocused = viewModel.id } label: { Image(systemName: viewModel.spoilerOn ? "exclamationmark.triangle.fill" : "exclamationmark.triangle") } @@ -180,12 +190,26 @@ struct StatusEditorAccessoryView: View { } } + 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 + } + private var draftsListView: some View { DraftsListView(selectedDraft: .init(get: { nil }, set: { draft in if let draft { - viewModel.insertStatusText(text: draft.content) + focusedSEVM.insertStatusText(text: draft.content) } })) } @@ -205,17 +229,17 @@ struct StatusEditorAccessoryView: View { Button { Task { isLoadingAIRequest = true - await viewModel.runOpenAI(prompt: prompt.toRequestPrompt(text: viewModel.statusText.string)) + await focusedSEVM.runOpenAI(prompt: prompt.toRequestPrompt(text: focusedSEVM.statusText.string)) isLoadingAIRequest = false } } label: { prompt.label } } - if let backup = viewModel.backupStatusText { + if let backup = focusedSEVM.backupStatusText { Button { - viewModel.replaceTextWith(text: backup.string) - viewModel.backupStatusText = nil + focusedSEVM.replaceTextWith(text: backup.string) + focusedSEVM.backupStatusText = nil } label: { Label("status.editor.restore-previous", systemImage: "arrow.uturn.right") } @@ -268,15 +292,15 @@ struct StatusEditorAccessoryView: View { name: language.localizedName ).tag(language.isoCode) Spacer() - if language.isoCode == viewModel.selectedLanguage { + if language.isoCode == focusedSEVM.selectedLanguage { Image(systemName: "checkmark") } } .listRowBackground(theme.primaryBackgroundColor) .contentShape(Rectangle()) .onTapGesture { - viewModel.selectedLanguage = language.isoCode - viewModel.hasExplicitlySelectedLanguage = true + focusedSEVM.selectedLanguage = language.isoCode + focusedSEVM.hasExplicitlySelectedLanguage = true isLanguageSheetDisplayed = false } } @@ -285,7 +309,7 @@ struct StatusEditorAccessoryView: View { private var customEmojisSheet: some View { NavigationStack { ScrollView { - ForEach(viewModel.customEmojiContainer) { container in + ForEach(focusedSEVM.customEmojiContainer) { container in VStack(alignment: .leading) { Text(container.categoryName) .font(.scaledFootnote) @@ -308,7 +332,7 @@ struct StatusEditorAccessoryView: View { } } .onTapGesture { - viewModel.insertStatusText(text: " :\(emoji.shortcode): ") + focusedSEVM.insertStatusText(text: " :\(emoji.shortcode): ") } } } @@ -332,7 +356,7 @@ struct StatusEditorAccessoryView: View { @ViewBuilder private var characterCountView: some View { - let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength + let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + focusedSEVM.statusTextCharacterLength Text("\(value)") .foregroundColor(value < 0 ? .red : .secondary) diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift index af589a00..2c228a24 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaEditView.swift @@ -105,6 +105,7 @@ struct StatusEditorMediaEditView: View { } } } + .preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light) } } diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift index 9c617644..4c8785ba 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorMediaView.swift @@ -10,7 +10,7 @@ struct StatusEditorMediaView: View { @Environment(Theme.self) private var theme @Environment(CurrentInstance.self) private var currentInstance var viewModel: StatusEditorViewModel - @Binding var editingContainer: StatusEditorMediaContainer? + @Binding var editingMediaContainer: StatusEditorMediaContainer? @State private var isErrorDisplayed: Bool = false @@ -56,9 +56,9 @@ struct StatusEditorMediaView: View { private var scrollBottomPadding : CGFloat? = 0 #endif - init(viewModel: StatusEditorViewModel, editingContainer: Binding) { + init(viewModel: StatusEditorViewModel, editingMediaContainer: Binding) { self.viewModel = viewModel - self._editingContainer = editingContainer + self._editingMediaContainer = editingMediaContainer } private func pixel(at index: Int) -> some View { @@ -175,7 +175,7 @@ struct StatusEditorMediaView: View { if container.mediaAttachment?.url != nil { if currentInstance.isEditAltTextSupported || !viewModel.mode.isEditing { Button { - editingContainer = container + editingMediaContainer = container } label: { Label(container.mediaAttachment?.description?.isEmpty == false ? "status.editor.description.edit" : "status.editor.description.add", @@ -211,7 +211,7 @@ struct StatusEditorMediaView: View { private func makeAltMarker(container: StatusEditorMediaContainer) -> some View { Button { - editingContainer = container + editingMediaContainer = container } label: { Text("status.image.alt-text.abbreviation") .font(.caption2) diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift new file mode 100644 index 00000000..33d795f6 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/StatusEditorCoreView.swift @@ -0,0 +1,158 @@ +import SwiftUI +import Models +import Env +import DesignSystem +import Accounts +import AppAccount +import Network + +@MainActor +struct StatusEditorCoreView: View { + @Bindable var viewModel: StatusEditorViewModel + @Binding var followUpSEVMs: [StatusEditorViewModel] + @Binding var editingMediaContainer: StatusEditorMediaContainer? + + @FocusState.Binding var isSpoilerTextFocused: UUID? + @FocusState.Binding var editorFocusState: StatusEditorFocusState? + let assignedFocusState: StatusEditorFocusState + let isMain: Bool + + @Environment(Theme.self) private var theme + @Environment(UserPreferences.self) private var preferences + @Environment(CurrentAccount.self) private var currentAccount + @Environment(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 + StatusEditorMediaView(viewModel: viewModel, editingMediaContainer: $editingMediaContainer) + embeddedStatus + pollView + } + .padding(.vertical) + + Divider() + } + .opacity(editorFocusState == assignedFocusState ? 1 : 0.6) + } + .background(theme.primaryBackgroundColor) + .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) + } + } + + private func setupViewModel() { + viewModel.client = client + viewModel.currentAccount = currentAccount.account + viewModel.theme = theme + viewModel.preferences = preferences + viewModel.prepareStatusText() + if !client.isAuth { +#if targetEnvironment(macCatalyst) + dismissWindow() +#else + dismiss() +#endif + NotificationCenter.default.post(name: .shareSheetClose, object: nil) + } + + Task { await viewModel.fetchCustomEmojis() } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift b/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift new file mode 100644 index 00000000..1bb54f1c --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/StatusEditorFocusState.swift @@ -0,0 +1,5 @@ +import SwiftUI + +enum StatusEditorFocusState: Hashable { + case main, followUp(index: UUID) +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift b/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift new file mode 100644 index 00000000..747022db --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/StatusEditorPrivacyMenu.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Models + +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 { self.visibility = vis } label: { + Label(vis.title, systemImage: vis.iconName) + } + } + } label: { + HStack { + Label(visibility.title, systemImage: visibility.iconName) + .accessibilityLabel("accessibility.editor.privacy.label") + .accessibilityValue(visibility.title) + .accessibilityHint("accessibility.editor.privacy.hint") + Image(systemName: "chevron.down") + } + .font(.scaledFootnote) + .padding(4) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(tint, lineWidth: 1) + ) + } + } +} + diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift b/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift new file mode 100644 index 00000000..020ca1be --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/StatusEditorToolbarItems.swift @@ -0,0 +1,136 @@ +import SwiftUI +import Env +import Models +import StoreKit + +@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 let openingPost = await postStatus(with: mainSEVM, isMainPost: true) else { return } + for p in followUpSEVMs { + p.mode = .replyTo(status: openingPost) + await postStatus(with: p, isMainPost: false) + } + } + +#if targetEnvironment(macCatalyst) + private func close() { dismissWindow() } +#else + private func close() { dismiss() } +#endif + + @ViewBuilder + private var languageConfirmationDialog: some View { + if let (detected: detected, selected: selected) = mainSEVM.languageConfirmationDialogLanguages, + let detectedLong = Locale.current.localizedString(forLanguageCode: detected), + let selectedLong = Locale.current.localizedString(forLanguageCode: selected) + { + Button("status.editor.language-select.confirmation.detected-\(detectedLong)") { + mainSEVM.selectedLanguage = detected + Task { await postAllStatus() } + } + Button("status.editor.language-select.confirmation.selected-\(selectedLong)") { + mainSEVM.selectedLanguage = selected + Task { await postAllStatus() } + } + Button("action.cancel", role: .cancel) { + mainSEVM.languageConfirmationDialogLanguages = nil + } + } else { + EmptyView() + } + } +} diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index ee5a01fe..8e87d5c2 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -14,290 +14,121 @@ import UIKit @MainActor public struct StatusEditorView: View { @Environment(AppAccountsManager.self) private var appAccounts - @Environment(UserPreferences.self) private var preferences - @Environment(Theme.self) private var theme - @Environment(Client.self) private var client @Environment(CurrentAccount.self) private var currentAccount - @Environment(\.dismiss) private var dismiss - @Environment(\.dismissWindow) private var dismissWindow - @Environment(\.modelContext) private var context + @Environment(Theme.self) private var theme - @State private var viewModel: StatusEditorViewModel - @FocusState private var isSpoilerTextFocused: Bool + @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? - @State private var isDismissAlertPresented: Bool = false - @State private var isLanguageConfirmPresented = false + @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 } - @State private var editingContainer: StatusEditorMediaContainer? + return mainSEVM + } public init(mode: StatusEditorViewModel.Mode) { - _viewModel = .init(initialValue: .init(mode: mode)) + _mainSEVM = State(initialValue: StatusEditorViewModel(mode: mode)) } public var body: some View { + @Bindable var focusedSEVM = self.focusedSEVM + NavigationStack { - ZStack(alignment: .bottom) { - ScrollView { - Divider() - spoilerTextView - VStack(spacing: 12) { - accountHeaderView - .padding(.horizontal, .layoutPadding) - TextView($viewModel.statusText, - getTextView: { textView in - viewModel.textView = textView - }) - .placeholder(String(localized: "status.editor.text.placeholder")) - .setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default) - .padding(.horizontal, .layoutPadding) - StatusEditorMediaView(viewModel: viewModel, - editingContainer: $editingContainer) - if let status = viewModel.embeddedStatus { - StatusEmbeddedView(status: status, client: client, routerPath: RouterPath()) - .padding(.horizontal, .layoutPadding) - .disabled(true) - } else if let status = viewModel.replyToStatus { - Divider() - .padding(.top, 20) - StatusEmbeddedView(status: status, client: client, routerPath: RouterPath()) - .padding(.horizontal, .layoutPadding) - .disabled(true) - } - if viewModel.showPoll { - StatusEditorPollView(viewModel: viewModel, showPoll: $viewModel.showPoll) - .padding(.horizontal) - } - Spacer() - } - .padding(.top, 8) - .padding(.bottom, 40) - } - .accessibilitySortPriority(1) // Ensure that all elements inside the `ScrollView` occur earlier than the accessory views - .padding(.top, 1) // hacky fix for weird SwiftUI scrollView bug when adding padding - .padding(.bottom, 48) - VStack(alignment: .leading, spacing: 0) { - StatusEditorAutoCompleteView(viewModel: viewModel) - StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, - viewModel: viewModel) - } - } - .onDrop(of: StatusEditorUTTypeSupported.types(), delegate: viewModel) - .onAppear { - viewModel.client = client - viewModel.currentAccount = currentAccount.account - viewModel.theme = theme - viewModel.preferences = preferences - viewModel.prepareStatusText() - if !client.isAuth { - close() - NotificationCenter.default.post(name: .shareSheetClose, - object: nil) - } - - Task { - await viewModel.fetchCustomEmojis() - } - } - .onChange(of: currentAccount.account?.id) { - viewModel.currentAccount = currentAccount.account - } - .background(theme.primaryBackgroundColor) - .navigationTitle(viewModel.mode.title) - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(.visible, for: .navigationBar) - .alert("status.error.posting.title", - isPresented: $viewModel.showPostingErrorAlert, - actions: { - Button("OK") {} - }, message: { - Text(viewModel.postingError ?? "") - }) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - Task { - viewModel.evaluateLanguages() - if preferences.autoDetectPostLanguage, let _ = viewModel.languageConfirmationDialogLanguages { - isLanguageConfirmPresented = true - } else { - await postStatus() - } - } - } label: { - if viewModel.isPosting { - ProgressView() - } else { - Text("status.action.post").bold() - } - } - .disabled(!viewModel.canPost) - .keyboardShortcut(.return, modifiers: .command) - .confirmationDialog("", isPresented: $isLanguageConfirmPresented, actions: { - languageConfirmationDialog - }) - } - ToolbarItem(placement: .navigationBarLeading) { - Button { - if viewModel.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: viewModel.statusText.string)) - close() - NotificationCenter.default.post(name: .shareSheetClose, - object: nil) - } - Button("action.cancel", role: .cancel) {} - } + ScrollView { + VStackLayout(spacing: 0) { + StatusEditorCoreView( + viewModel: mainSEVM, + followUpSEVMs: $followUpSEVMs, + editingMediaContainer: $editingMediaContainer, + isSpoilerTextFocused: $isSpoilerTextFocused, + editorFocusState: $editorFocusState, + assignedFocusState: .main, + isMain: true ) - } - } - } - .sheet(item: $editingContainer) { container in - StatusEditorMediaEditView(viewModel: viewModel, container: container) - .preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light) - } - .interactiveDismissDisabled(viewModel.shouldDisplayDismissWarning) - .onChange(of: appAccounts.currentClient) { _, newValue in - if viewModel.mode.isInShareExtension { - currentAccount.setClient(client: newValue) - viewModel.client = newValue - } - } - } + .id(mainSEVM.id) - @ViewBuilder - private var languageConfirmationDialog: some View { - if let (detected: detected, selected: selected) = viewModel.languageConfirmationDialogLanguages, - let detectedLong = Locale.current.localizedString(forLanguageCode: detected), - let selectedLong = Locale.current.localizedString(forLanguageCode: selected) - { - Button("status.editor.language-select.confirmation.detected-\(detectedLong)") { - viewModel.selectedLanguage = detected - Task { - await postStatus() - } - } - Button("status.editor.language-select.confirmation.selected-\(selectedLong)") { - viewModel.selectedLanguage = selected - Task { - await postStatus() - } - } - Button("action.cancel", role: .cancel) { - viewModel.languageConfirmationDialogLanguages = nil - } - } else { - EmptyView() - } - } + ForEach(followUpSEVMs) { sevm in + @Bindable var sevm: StatusEditorViewModel = sevm - private func postStatus() async { - let status = await viewModel.postStatus() - if status != nil { - close() - SoundEffectManager.shared.playSound(.tootSent) - NotificationCenter.default.post(name: .shareSheetClose, - object: nil) -#if !targetEnvironment(macCatalyst) - if !viewModel.mode.isInShareExtension, !preferences.requestedReview { - if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { - SKStoreReviewController.requestReview(in: scene) + StatusEditorCoreView( + viewModel: sevm, + followUpSEVMs: $followUpSEVMs, + editingMediaContainer: $editingMediaContainer, + isSpoilerTextFocused: $isSpoilerTextFocused, + editorFocusState: $editorFocusState, + assignedFocusState: .followUp(index: sevm.id), + isMain: false + ) + .id(sevm.id) + } } - preferences.requestedReview = true + .scrollTargetLayout() } -#endif - } - } - - @ViewBuilder - private var spoilerTextView: some View { - if viewModel.spoilerOn { - VStack { - TextField("status.editor.spoiler", text: $viewModel.spoilerText) - .focused($isSpoilerTextFocused) - .padding(.horizontal, .layoutPadding) + .scrollPosition(id: $scrollID, anchor: .top) + .animation(.bouncy(duration: 0.3), value: editorFocusState) + .animation(.bouncy(duration: 0.3), value: followUpSEVMs) + .background(Color.primaryBackground) + .safeAreaInset(edge: .bottom) { + StatusEditorAutoCompleteView(viewModel: focusedSEVM) } - .frame(height: 35) - .background(theme.tintColor.opacity(0.20)) - .offset(y: -8) - } - } - - @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 - Text("@\(account.acct)@\(appAccounts.currentClient.server)") - .font(.scaledFootnote) - .foregroundStyle(.secondary) - } - Spacer() + .safeAreaInset(edge: .bottom) { + StatusEditorAccessoryView(isSpoilerTextFocused: $isSpoilerTextFocused, focusedSEVM: focusedSEVM, followUpSEVMs: $followUpSEVMs) } - } - } - - private var privacyMenu: some View { - Menu { - Section("status.editor.visibility") { - ForEach(Models.Visibility.allCases, id: \.self) { visibility in - Button { - viewModel.visibility = visibility - } label: { - Label(visibility.title, systemImage: visibility.iconName) + .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 } } } - } label: { - HStack { - Label(viewModel.visibility.title, systemImage: viewModel.visibility.iconName) - .accessibilityLabel("accessibility.editor.privacy.label") - .accessibilityValue(viewModel.visibility.title) - .accessibilityHint("accessibility.editor.privacy.hint") - Image(systemName: "chevron.down") + .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 + } + } + } } - .font(.scaledFootnote) - .padding(4) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(theme.tintColor, lineWidth: 1) - ) } - } - - private func close() { -#if targetEnvironment(macCatalyst) - dismissWindow() -#else - dismiss() -#endif + .sheet(item: $editingMediaContainer) { container in + StatusEditorMediaEditView(viewModel: focusedSEVM, container: container) + } } } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 44ae9b8c..63009a8a 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -8,7 +8,9 @@ import PhotosUI import SwiftUI @MainActor -@Observable public class StatusEditorViewModel: NSObject { +@Observable public class StatusEditorViewModel: NSObject, Identifiable { + public let id = UUID() + var mode: Mode var client: Client?