import DesignSystem import Env import Models import NukeUI import PhotosUI import SwiftUI @MainActor struct StatusEditorAccessoryView: View { @Environment(UserPreferences.self) private var preferences @Environment(Theme.self) private var theme @Environment(CurrentInstance.self) private var currentInstance @Environment(\.colorScheme) private var colorScheme @FocusState.Binding var isSpoilerTextFocused: Bool var viewModel: 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 var body: some View { @Bindable var viewModel = viewModel VStack(spacing: 0) { Divider() HStack { ScrollView(.horizontal) { HStack(alignment: .center, spacing: 16) { Menu { Button { isPhotosPickerPresented = true } label: { Label("status.editor.photo-library", systemImage: "photo") } if !ProcessInfo.processInfo.isMacCatalystApp { Button { isCameraPickerPresented = true } label: { Label("status.editor.camera-picker", systemImage: "camera") } } Button { isFileImporterPresented = true } label: { Label("status.editor.browse-file", systemImage: "folder") } } label: { if viewModel.isMediasLoading { ProgressView() } else { Image(systemName: "photo.on.rectangle.angled") } } .photosPicker(isPresented: $isPhotosPickerPresented, selection: $viewModel.selectedMedias, maxSelectionCount: 4, matching: .any(of: [.images, .videos])) .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) }) .accessibilityLabel("accessibility.editor.button.attach-photo") .disabled(viewModel.showPoll) 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.toggle() } 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.customEmojis.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 { 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) } } .padding(.horizontal, .layoutPadding) } Spacer() characterCountView .padding(.trailing, .layoutPadding) } .frame(height: 20) .padding(.vertical, 12) .background(.ultraThinMaterial) } .onAppear { viewModel.setInitialLanguageSelection(preference: preferences.recentlyUsedLanguages.first ?? preferences.serverPreferences?.postLanguage) } } private var draftsListView: some View { DraftsListView(selectedDraft: .init(get: { nil }, set: { draft in if let draft { viewModel.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 viewModel.runOpenAI(prompt: prompt.toRequestPrompt(text: viewModel.statusText.string)) isLoadingAIRequest = false } } label: { prompt.label } } if let backup = viewModel.backupStatusText { Button { viewModel.replaceTextWith(text: backup.string) viewModel.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 == viewModel.selectedLanguage { Image(systemName: "checkmark") } } .listRowBackground(theme.primaryBackgroundColor) .contentShape(Rectangle()) .onTapGesture { viewModel.selectedLanguage = language.isoCode viewModel.hasExplicitlySelectedLanguage = true isLanguageSheetDisplayed = false } } } private var customEmojisSheet: some View { NavigationStack { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 40))], spacing: 9) { ForEach(viewModel.customEmojis) { 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 { viewModel.insertStatusText(text: " :\(emoji.shortcode): ") } } }.padding(.horizontal) } .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]) } @ViewBuilder private var characterCountView: some View { let value = (currentInstance.instance?.configuration?.statuses.maxCharacters ?? 500) + viewModel.statusTextCharacterLength Text("\(value)") .foregroundColor(value < 0 ? .red : .secondary) .font(.scaledCallout) .accessibilityLabel("accessibility.editor.button.characters-remaining") .accessibilityValue("\(value)") .accessibilityRemoveTraits(.isStaticText) .accessibilityAddTraits(.updatesFrequently) .accessibilityRespondsToUserInteraction(false) } 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 } } }