diff --git a/Packages/Models/Sources/Models/Status.swift b/Packages/Models/Sources/Models/Status.swift index e772d198..9ed8f466 100644 --- a/Packages/Models/Sources/Models/Status.swift +++ b/Packages/Models/Sources/Models/Status.swift @@ -50,6 +50,7 @@ public protocol AnyStatus { var spoilerText: String { get } var filtered: [Filtered]? { get } var sensitive: Bool { get } + var language: String? { get } } @@ -83,6 +84,7 @@ public struct Status: AnyStatus, Codable, Identifiable { public let spoilerText: String public let filtered: [Filtered]? public let sensitive: Bool + public let language: String? public static func placeholder() -> Status { .init(id: UUID().uuidString, @@ -109,7 +111,8 @@ public struct Status: AnyStatus, Codable, Identifiable { poll: nil, spoilerText: "", filtered: [], - sensitive: false) + sensitive: false, + language: nil) } public static func placeholders() -> [Status] { @@ -146,4 +149,5 @@ public struct ReblogStatus: AnyStatus, Codable, Identifiable { public let spoilerText: String public let filtered: [Filtered]? public let sensitive: Bool + public let language: String? } diff --git a/Packages/Network/Sources/Network/Endpoint/Statuses.swift b/Packages/Network/Sources/Network/Endpoint/Statuses.swift index d6b0217f..5a33576b 100644 --- a/Packages/Network/Sources/Network/Endpoint/Statuses.swift +++ b/Packages/Network/Sources/Network/Endpoint/Statuses.swift @@ -80,7 +80,8 @@ public struct StatusData: Encodable { public let spoilerText: String? public let mediaIds: [String]? public let poll: PollData? - + public let language: String? + public struct PollData: Encodable { public let options: [String] public let multiple: Bool @@ -98,12 +99,14 @@ public struct StatusData: Encodable { inReplyToId: String? = nil, spoilerText: String? = nil, mediaIds: [String]? = nil, - poll: PollData? = nil) { + poll: PollData? = nil, + language: String? = nil) { self.status = status self.visibility = visibility self.inReplyToId = inReplyToId self.spoilerText = spoilerText self.mediaIds = mediaIds self.poll = poll + self.language = language } } diff --git a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift index 31b90d19..94595cb4 100644 --- a/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift +++ b/Packages/Status/Sources/Status/Editor/Components/StatusEditorAccessoryView.swift @@ -5,15 +5,16 @@ import Models import Env struct StatusEditorAccessoryView: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var preferences: UserPreferences @EnvironmentObject private var theme: Theme @EnvironmentObject private var currentInstance: CurrentInstance @FocusState.Binding var isSpoilerTextFocused: Bool @ObservedObject var viewModel: StatusEditorViewModel + @State private var isDrafsSheetDisplayed: Bool = false + @State private var isLanguageSheetDisplayed: Bool = false + @State private var languageSearch: String = "" var body: some View { VStack(spacing: 0) { @@ -25,18 +26,6 @@ struct StatusEditorAccessoryView: View { } .disabled(viewModel.showPoll) - Button { - viewModel.insertStatusText(text: " @") - } label: { - Image(systemName: "at") - } - - Button { - viewModel.insertStatusText(text: " #") - } label: { - Image(systemName: "number") - } - Button { withAnimation { viewModel.showPoll.toggle() @@ -61,9 +50,18 @@ struct StatusEditorAccessoryView: View { } label: { Image(systemName: "archivebox") } - } - + + Button { + isLanguageSheetDisplayed.toggle() + } label: { + if let language = viewModel.selectedLanguage { + Text(language.uppercased()) + } else { + Image(systemName: "globe") + } + } + Spacer() characterCountView @@ -76,6 +74,54 @@ struct StatusEditorAccessoryView: View { .sheet(isPresented: $isDrafsSheetDisplayed) { draftsSheetView } + .sheet(isPresented: $isLanguageSheetDisplayed, content: { + languageSheetView + }) + .onAppear { + viewModel.setInitialLanguageSelection(preference: preferences.serverPreferences?.postLanguage) + } + } + + @ViewBuilder + private func languageTextView(isoCode: String, nativeName: String?, name: String?) -> some View { + if let nativeName = nativeName, let name = name { + Text("\(nativeName) (\(name))") + } else { + Text(isoCode.uppercased()) + } + } + + private var languageSheetView: some View { + NavigationStack { + List { + ForEach(availableLanguages, id: \.0) { (isoCode, nativeName, name) in + HStack { + languageTextView(isoCode: isoCode, nativeName: nativeName, name: name) + .tag(isoCode) + Spacer() + if isoCode == viewModel.selectedLanguage { + Image(systemName: "checkmark") + } + } + .listRowBackground(theme.primaryBackgroundColor) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.selectedLanguage = isoCode + isLanguageSheetDisplayed = false + } + } + } + .searchable(text: $languageSearch) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel", action: { isLanguageSheetDisplayed = false }) + } + } + .navigationTitle("Select Languages") + .navigationBarTitleDisplayMode(.inline) + .scrollContentBackground(.hidden) + .background(theme.secondaryBackgroundColor) + } } private var draftsSheetView: some View { @@ -98,7 +144,7 @@ struct StatusEditorAccessoryView: View { } .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel", action: { dismiss() }) + Button("Cancel", action: { isDrafsSheetDisplayed = false }) } } .scrollContentBackground(.hidden) @@ -115,4 +161,23 @@ struct StatusEditorAccessoryView: View { .foregroundColor(.gray) .font(.callout) } + + private var availableLanguages: [(String, String?, String?)] { + Locale.LanguageCode.isoLanguageCodes + .filter { $0.identifier.count == 2 } // Mastodon only supports ISO 639-1 (two-letter) codes + .map { lang in + let nativeLocale = Locale(languageComponents: Locale.Language.Components(languageCode: lang)) + return ( + lang.identifier, + nativeLocale.localizedString(forLanguageCode: lang.identifier), + Locale.current.localizedString(forLanguageCode: lang.identifier) + ) + } + .filter { (identifier, nativeLocale, locale) in + guard !languageSearch.isEmpty else { + return true + } + return nativeLocale?.lowercased().hasPrefix(languageSearch.lowercased()) == true + } + } } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 59ea1cf1..46583259 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -63,6 +63,7 @@ public class StatusEditorViewModel: ObservableObject { @Published var mentionsSuggestions: [Account] = [] @Published var tagsSuggestions: [Tag] = [] + @Published var selectedLanguage: String? private var currentSuggestionRange: NSRange? private var embededStatusURL: URL? { @@ -94,6 +95,17 @@ public class StatusEditorViewModel: ObservableObject { statusText = .init(string: text) selectedRange = .init(location: text.utf16.count, length: 0) } + + func setInitialLanguageSelection(preference: String?) { + switch mode { + case .replyTo(let status), .edit(let status): + selectedLanguage = status.language + default: + break + } + + selectedLanguage = selectedLanguage ?? preference ?? currentAccount?.source?.language + } private func getPollOptionsForAPI() -> [String]? { let options = pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } @@ -116,7 +128,8 @@ public class StatusEditorViewModel: ObservableObject { inReplyToId: mode.replyToStatus?.id, spoilerText: spoilerOn ? spoilerText : nil, mediaIds: mediasImages.compactMap{ $0.mediaAttachement?.id }, - poll: pollData) + poll: pollData, + language: selectedLanguage) switch mode { case .new, .replyTo, .quote, .mention, .shareExtension: postStatus = try await client.post(endpoint: Statuses.postStatus(json: data))