From 0a9689c67f3bc471a0e927512d6e16cdc9da483a Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Mon, 23 Jan 2023 19:50:10 -0500 Subject: [PATCH] Add support for selecting the post language (#907) * Basic fake language picker support * Recognize languages from post text * Exclude suggested languages from recents * Load recent languages from Settings object * Send the language to the API * Persist the used language to settings * Always show the currently selected language in the list * Fix crash * Add support for picking arbitrary lanuages * Fix display of 3 letter language codes * Improve label to include endonym too * Limit to 3 recent languages * Reduce lower bound for displaying language suggestions * Fix saving recent language when publishing * Fix tint color of language picker button * Add a badge to prompt users to change language * Dismiss the badge even if you pick the same language * Read language names in the language if possible * Use a compressed font for 3-letter codes Also use `minimumScaleFactor` to shrink troublesome codes to fit Co-Authored-By: samhenrigold <49251320+samhenrigold@users.noreply.github.com> * Remove .vscode/launch.json * Add message to fatalError() Co-authored-by: samhenrigold <49251320+samhenrigold@users.noreply.github.com> --- .../input/Base.lproj/app.json | 6 ++ Localization/app.json | 6 ++ .../Handler/SendPostIntentHandler.swift | 3 +- .../CoreData 7.xcdatamodel/contents | 1 + .../CoreDataStack/Entity/App/Setting.swift | 13 +++ .../Generated/Strings.swift | 10 ++ .../Resources/Base.lproj/Localizable.strings | 4 + .../API/Mastodon+API+Statuses.swift | 6 +- .../ComposeContentViewController.swift | 48 +++++++++- .../ComposeContentViewModel.swift | 33 ++++++- .../Publisher/MastodonStatusPublisher.swift | 9 +- .../ComposeContentToolbarView+ViewModel.swift | 14 +++ .../Toolbar/ComposeContentToolbarView.swift | 95 +++++++++++++++++++ .../ComposeContent/Toolbar/Language.swift | 41 ++++++++ .../Toolbar/LanguagePicker.swift | 72 ++++++++++++++ 15 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift diff --git a/Localization/StringsConvertor/input/Base.lproj/app.json b/Localization/StringsConvertor/input/Base.lproj/app.json index 23180ac89..b9603ce25 100644 --- a/Localization/StringsConvertor/input/Base.lproj/app.json +++ b/Localization/StringsConvertor/input/Base.lproj/app.json @@ -485,6 +485,12 @@ "toggle_content_warning": "Toggle Content Warning", "append_attachment_entry": "Add Attachment - %s", "select_visibility_entry": "Select Visibility - %s" + }, + "language": { + "title": "Post Language", + "suggested": "Suggested", + "recent": "Recent", + "other": "Other Language…" } }, "profile": { diff --git a/Localization/app.json b/Localization/app.json index 0f471d973..3150c0d24 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -502,6 +502,12 @@ "toggle_content_warning": "Toggle Content Warning", "append_attachment_entry": "Add Attachment - %s", "select_visibility_entry": "Select Visibility - %s" + }, + "language": { + "title": "Post Language", + "suggested": "Suggested", + "recent": "Recent", + "other": "Other Language…" } }, "profile": { diff --git a/MastodonIntent/Handler/SendPostIntentHandler.swift b/MastodonIntent/Handler/SendPostIntentHandler.swift index afee7d581..e3b19a2e9 100644 --- a/MastodonIntent/Handler/SendPostIntentHandler.swift +++ b/MastodonIntent/Handler/SendPostIntentHandler.swift @@ -87,7 +87,8 @@ extension SendPostIntentHandler: SendPostIntentHandling { inReplyToID: nil, sensitive: nil, spoilerText: nil, - visibility: visibility + visibility: visibility, + language: nil ), authenticationBox: authenticationBox ) diff --git a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 7.xcdatamodel/contents b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 7.xcdatamodel/contents index 7198c75c6..53ae9db7a 100644 --- a/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 7.xcdatamodel/contents +++ b/MastodonSDK/Sources/CoreDataStack/CoreData.xcdatamodeld/CoreData 7.xcdatamodel/contents @@ -193,6 +193,7 @@ + diff --git a/MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift b/MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift index 94ee50959..b25d01f92 100644 --- a/MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift +++ b/MastodonSDK/Sources/CoreDataStack/Entity/App/Setting.swift @@ -22,6 +22,19 @@ public final class Setting: NSManagedObject { @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date + @NSManaged private var rawRecentLanguages: Data? + @objc dynamic public var recentLanguages: [String] { + get { + if let data = rawRecentLanguages, let result = try? JSONDecoder().decode([String].self, from: data) { + return result + } + return [] + } + set { + rawRecentLanguages = try? JSONEncoder().encode(Array(newValue.prefix(3))) + } + } + // one-to-many relationships @NSManaged public var subscriptions: Set? } diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index fb516496a..d78e95231 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -574,6 +574,16 @@ public enum L10n { /// Toggle Poll public static let togglePoll = L10n.tr("Localizable", "Scene.Compose.Keyboard.TogglePoll", fallback: "Toggle Poll") } + public enum Language { + /// Other Language… + public static let other = L10n.tr("Localizable", "Scene.Compose.Language.Other", fallback: "Other Language…") + /// Recent + public static let recent = L10n.tr("Localizable", "Scene.Compose.Language.Recent", fallback: "Recent") + /// Suggested + public static let suggested = L10n.tr("Localizable", "Scene.Compose.Language.Suggested", fallback: "Suggested") + /// Post Language + public static let title = L10n.tr("Localizable", "Scene.Compose.Language.Title", fallback: "Post Language") + } public enum MediaSelection { /// Browse public static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse", fallback: "Browse") diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 2c2d295b3..b795752ed 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -204,6 +204,10 @@ uploaded to Mastodon."; "Scene.Compose.Keyboard.SelectVisibilityEntry" = "Select Visibility - %@"; "Scene.Compose.Keyboard.ToggleContentWarning" = "Toggle Content Warning"; "Scene.Compose.Keyboard.TogglePoll" = "Toggle Poll"; +"Scene.Compose.Language.Other" = "Other Language…"; +"Scene.Compose.Language.Recent" = "Recent"; +"Scene.Compose.Language.Suggested" = "Suggested"; +"Scene.Compose.Language.Title" = "Post Language"; "Scene.Compose.MediaSelection.Browse" = "Browse"; "Scene.Compose.MediaSelection.Camera" = "Take Photo"; "Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index 0fa1a0d61..853993fcc 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -106,6 +106,7 @@ extension Mastodon.API.Statuses { public let sensitive: Bool? public let spoilerText: String? public let visibility: Mastodon.Entity.Status.Visibility? + public let language: String? public init( status: String?, @@ -115,7 +116,8 @@ extension Mastodon.API.Statuses { inReplyToID: Mastodon.Entity.Status.ID?, sensitive: Bool?, spoilerText: String?, - visibility: Mastodon.Entity.Status.Visibility? + visibility: Mastodon.Entity.Status.Visibility?, + language: String? ) { self.status = status self.mediaIDs = mediaIDs @@ -125,6 +127,7 @@ extension Mastodon.API.Statuses { self.sensitive = sensitive self.spoilerText = spoilerText self.visibility = visibility + self.language = language } var contentType: String? { @@ -146,6 +149,7 @@ extension Mastodon.API.Statuses { sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) } + language.flatMap { data.append(Data.multipart(key: "language", value: $0)) } data.append(Data.multipartEnd()) return data diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 319804fb1..f3efb9957 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -11,6 +11,7 @@ import SwiftUI import Combine import PhotosUI import MastodonCore +import NaturalLanguage public final class ComposeContentViewController: UIViewController { @@ -334,6 +335,51 @@ extension ComposeContentViewController { viewModel.$contentWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWeightedLength) viewModel.$contentWarningWeightedLength.assign(to: &composeContentToolbarViewModel.$contentWarningWeightedLength) + let languageRecognizer = NLLanguageRecognizer() + viewModel.$content + // run on background thread since NLLanguageRecognizer seems to do CPU-bound work + // that we don’t want on main + .receive(on: DispatchQueue.global(qos: .utility)) + .sink { [unowned self] content in + if content.isEmpty { + DispatchQueue.main.async { + self.composeContentToolbarViewModel.suggestedLanguages = [] + } + return + } + defer { languageRecognizer.reset() } + languageRecognizer.processString(content) + let hypotheses = languageRecognizer + .languageHypotheses(withMaximum: 3) + DispatchQueue.main.async { + self.composeContentToolbarViewModel.suggestedLanguages = hypotheses + .filter { _, probability in probability > 0.1 } + .keys + .map(\.rawValue) + + if let bestLanguage = hypotheses.max(by: { $0.value < $1.value }), bestLanguage.value > 0.99 { + self.composeContentToolbarViewModel.highConfidenceSuggestedLanguage = bestLanguage.key.rawValue + } else { + self.composeContentToolbarViewModel.highConfidenceSuggestedLanguage = nil + } + } + } + .store(in: &disposeBag) + + viewModel.$language.assign(to: &composeContentToolbarViewModel.$language) + composeContentToolbarViewModel.$language + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] language in + guard let self = self else { return } + if self.viewModel.language != language { + self.viewModel.language = language + } + } + .store(in: &disposeBag) + + viewModel.$recentLanguages.assign(to: &composeContentToolbarViewModel.$recentLanguages) + // bind back to source due to visibility not update via delegate composeContentToolbarViewModel.$visibility .dropFirst() @@ -507,7 +553,7 @@ extension ComposeContentViewController: ComposeContentToolbarViewDelegate { self.viewModel.setContentTextViewFirstResponderIfNeeds() } } - case .visibility: + case .visibility, .language: assertionFailure() } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 066a7ff78..60ce2c25e 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -112,6 +112,10 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // visibility @Published public var visibility: Mastodon.Entity.Status.Visibility + // language + @Published public var language: String + @Published public private(set) var recentLanguages: [String] + // UI & UX @Published var replyToCellFrame: CGRect = .zero @Published var contentCellFrame: CGRect = .zero @@ -178,6 +182,10 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel( for: authContext.mastodonAuthenticationBox.domain ) + + let recentLanguages = context.settingService.currentSetting.value?.recentLanguages ?? [] + self.recentLanguages = recentLanguages + self.language = recentLanguages.first ?? Locale.current.languageCode ?? "en" super.init() // end init @@ -423,6 +431,19 @@ extension ComposeContentViewModel { return content.trimmingCharacters(in: .whitespacesAndNewlines) == self.initialContent } .assign(to: &$shouldDismiss) + + // languages + context.settingService.currentSetting + .flatMap { settings in + if let settings { + return settings.publisher(for: \.recentLanguages, options: .initial).eraseToAnyPublisher() + } else if let code = Locale.current.languageCode { + return Just([code]).eraseToAnyPublisher() + } + return Just([]).eraseToAnyPublisher() + } + .assign(to: &$recentLanguages) + } } @@ -509,6 +530,15 @@ extension ComposeContentViewModel { } }() + // save language to recent languages + if let settings = context.settingService.currentSetting.value { + Task.detached(priority: .background) { [language] in + try await settings.managedObjectContext?.performChanges { + settings.recentLanguages = [language] + settings.recentLanguages.filter { $0 != language } + } + } + } + return MastodonStatusPublisher( author: author, replyTo: { @@ -526,7 +556,8 @@ extension ComposeContentViewModel { pollOptions: pollOptions, pollExpireConfigurationOption: pollExpireConfigurationOption, pollMultipleConfigurationOption: pollMultipleConfigurationOption, - visibility: visibility + visibility: visibility, + language: language ) } // end func publisher() } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift index 3cecbc675..d597c80f2 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -37,6 +37,8 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { public let pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option // visibility public let visibility: Mastodon.Entity.Status.Visibility + // language + public let language: String // Output let _progress = Progress() @@ -58,7 +60,8 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { pollOptions: [PollComposeItem.Option], pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option, pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option, - visibility: Mastodon.Entity.Status.Visibility + visibility: Mastodon.Entity.Status.Visibility, + language: String ) { self.author = author self.replyTo = replyTo @@ -72,6 +75,7 @@ public final class MastodonStatusPublisher: NSObject, ProgressReporting { self.pollExpireConfigurationOption = pollExpireConfigurationOption self.pollMultipleConfigurationOption = pollMultipleConfigurationOption self.visibility = visibility + self.language = language } } @@ -169,7 +173,8 @@ extension MastodonStatusPublisher: StatusPublisher { inReplyToID: inReplyToID, sensitive: isMediaSensitive, spoilerText: isContentWarningComposing ? contentWarning : nil, - visibility: visibility + visibility: visibility, + language: language ) let publishResponse = try await api.publishStatus( diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift index 6374203fa..440af795e 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift @@ -18,6 +18,8 @@ extension ComposeContentToolbarView { // input @Published var backgroundColor = ThemeService.shared.currentTheme.value.composeToolbarBackgroundColor + @Published var suggestedLanguages: [String] = [] + @Published var highConfidenceSuggestedLanguage: String? @Published var visibility: Mastodon.Entity.Status.Visibility = .public var allVisibilities: [Mastodon.Entity.Status.Visibility] { return [.public, .private, .direct] @@ -30,6 +32,9 @@ extension ComposeContentToolbarView { @Published var isAttachmentButtonEnabled = false @Published var isPollButtonEnabled = false + @Published var language = Locale.current.languageCode ?? "en" + @Published var recentLanguages: [String] = [] + @Published public var maxTextInputLimit = 500 @Published public var contentWeightedLength = 0 @Published public var contentWarningWeightedLength = 0 @@ -55,6 +60,7 @@ extension ComposeContentToolbarView.ViewModel { case emoji case contentWarning case visibility + case language var activeImage: UIImage { switch self { @@ -68,6 +74,8 @@ extension ComposeContentToolbarView.ViewModel { return Asset.Scene.Compose.chatWarningFill.image.withRenderingMode(.alwaysTemplate) case .visibility: return Asset.Scene.Compose.earth.image.withRenderingMode(.alwaysTemplate) + case .language: + fatalError("Language’s active image is never accessed") } } @@ -83,6 +91,8 @@ extension ComposeContentToolbarView.ViewModel { return Asset.Scene.Compose.chatWarning.image.withRenderingMode(.alwaysTemplate) case .visibility: return Asset.Scene.Compose.earth.image.withRenderingMode(.alwaysTemplate) + case .language: + fatalError("Language’s inactive image is never accessed") } } } @@ -119,6 +129,8 @@ extension ComposeContentToolbarView.ViewModel { return isEmojiActive ? action.activeImage : action.inactiveImage case .contentWarning: return isContentWarningActive ? action.activeImage : action.inactiveImage + case .language: + fatalError("Language’s image is never accessed") default: return action.inactiveImage } @@ -136,6 +148,8 @@ extension ComposeContentToolbarView.ViewModel { return isContentWarningActive ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning case .visibility: return L10n.Scene.Compose.Accessibility.postVisibilityMenu + case .language: + return "[[language]]" } } } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index 683164bdd..68e0112f4 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -24,6 +24,12 @@ struct ComposeContentToolbarView: View { @ObservedObject var viewModel: ViewModel + @State private var showingLanguagePicker = false + @State private var didChangeLanguage = false + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @Environment(\.verticalSizeClass) var verticalSizeClass + var body: some View { HStack(spacing: .zero) { ForEach(ComposeContentToolbarView.ViewModel.Action.allCases, id: \.self) { action in @@ -76,6 +82,84 @@ struct ComposeContentToolbarView: View { } .disabled(!viewModel.isPollButtonEnabled) .frame(width: 48, height: 48) + case .language: + Menu { + Section {} // workaround a bug where the “Suggested” section doesn’t appear + if !viewModel.suggestedLanguages.isEmpty { + Section(L10n.Scene.Compose.Language.suggested) { + ForEach(viewModel.suggestedLanguages.compactMap(Language.init(id:))) { lang in + Toggle(isOn: languageBinding(for: lang.id)) { + Text(lang.label) + } + } + } + } + let recent = viewModel.recentLanguages.filter { !viewModel.suggestedLanguages.contains($0) } + if !recent.isEmpty { + Section(L10n.Scene.Compose.Language.recent) { + ForEach(recent.compactMap(Language.init(id:))) { lang in + Toggle(isOn: languageBinding(for: lang.id)) { + Text(lang.label) + } + } + } + } + if !(recent + viewModel.suggestedLanguages).contains(viewModel.language) { + Toggle(isOn: languageBinding(for: viewModel.language)) { + Text(Language(id: viewModel.language)?.label ?? AttributedString("\(viewModel.language)")) + } + } + Button(L10n.Scene.Compose.Language.other) { + showingLanguagePicker = true + } + } label: { + let font: SwiftUI.Font = { + if #available(iOS 16, *) { + return .system(size: 11, weight: .semibold).width(viewModel.language.count == 3 ? .compressed : .standard) + } else { + return .system(size: 11, weight: .semibold) + } + }() + + Text(viewModel.language) + .font(font) + .textCase(.uppercase) + .padding(.horizontal, 4) + .minimumScaleFactor(0.5) + .frame(width: 24, height: 24, alignment: .center) + .overlay { RoundedRectangle(cornerRadius: 7).inset(by: 3).stroke(lineWidth: 1.5) } + .accessibilityLabel(L10n.Scene.Compose.Language.title) + .accessibilityValue(Text(Language(id: viewModel.language)?.label ?? AttributedString("\(viewModel.language)"))) + .foregroundColor(Color(Asset.Scene.Compose.buttonTint.color)) + .overlay(alignment: .topTrailing) { + Group { + if let suggested = viewModel.highConfidenceSuggestedLanguage, + suggested != viewModel.language, + !didChangeLanguage { + Circle().fill(.blue) + .frame(width: 8, height: 8) + } + } + .transition(.opacity) + .animation(.default, value: [viewModel.highConfidenceSuggestedLanguage, viewModel.language]) + } + // fixes weird appearance when drawing at low opacity (eg when pressed) + .drawingGroup() + } + .frame(width: 48, height: 48) + .popover(isPresented: $showingLanguagePicker) { + let picker = LanguagePicker { newLanguage in + viewModel.language = newLanguage + didChangeLanguage = true + showingLanguagePicker = false + } + if verticalSizeClass == .regular && horizontalSizeClass == .regular { + // explicitly size picker when it’s a popover + picker.frame(width: 400, height: 500) + } else { + picker + } + } default: Button { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))") @@ -124,6 +208,17 @@ extension ComposeContentToolbarView { .foregroundColor(Color(Asset.Scene.Compose.buttonTint.color)) .frame(width: 24, height: 24, alignment: .center) } + + private func languageBinding(for code: String) -> Binding { + Binding { + code == viewModel.language + } set: { newValue in + if newValue { + viewModel.language = code + } + didChangeLanguage = true + } + } } extension Mastodon.Entity.Status.Visibility { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift new file mode 100644 index 000000000..c24b788f8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/Language.swift @@ -0,0 +1,41 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation + +// Consider replacing this with Locale.Language when dropping iOS 15 +struct Language: Identifiable { + let endonym: String + let exonym: String + let id: String + let localeId: String? + + init(endonym: String, exonym: String, id: String, localeId: String?) { + self.endonym = endonym + self.exonym = exonym + self.id = id + self.localeId = localeId + } + + init?(id: String) { + guard let endonym = Locale(identifier: id).localizedString(forLanguageCode: id), + let exonym = Locale.current.localizedString(forLanguageCode: id) + else { return nil } + self.endonym = endonym + self.exonym = exonym + self.id = id + self.localeId = nil + } + + func contains(_ query: String) -> Bool { + "\(endonym) \(exonym) \(id)".localizedCaseInsensitiveContains(query) + } + + var exonymIsDifferent: Bool { + endonym.caseInsensitiveCompare(exonym) != .orderedSame + } + + var label: AttributedString { + AttributedString(endonym, attributes: AttributeContainer([.languageIdentifier: id])) + + AttributedString(exonymIsDifferent ? " (\(exonym))" : "") + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift new file mode 100644 index 000000000..f51dd6732 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/LanguagePicker.swift @@ -0,0 +1,72 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import MastodonLocalization +import SwiftUI + +struct LanguagePicker: View { + let onSelect: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @State private var query = "" + @State private var languages: [Language] = { + let locales = Locale.availableIdentifiers.map(Locale.init(identifier:)) + var languages: [String: Language] = [:] + for locale in locales { + if let code = locale.languageCode, + let endonym = locale.localizedString(forLanguageCode: code), + let exonym = Locale.current.localizedString(forLanguageCode: code) { + // don’t overwrite the “base” language + if let lang = languages[code], !(lang.localeId ?? "").contains("_") { continue } + languages[code] = Language(endonym: endonym, exonym: exonym, id: code, localeId: locale.identifier) + } + } + return languages.values.sorted(using: KeyPathComparator(\.id)) + }() + + var body: some View { + NavigationView { + let filteredLanguages = query.isEmpty ? languages : languages.filter { $0.contains(query) } + List(filteredLanguages) { lang in + let endonym = Text(lang.endonym) + let exonym: Text = { + if lang.exonymIsDifferent { + return Text("(\(lang.exonym))").foregroundColor(.secondary) + } + return Text("") + }() + Button(action: { onSelect(lang.id) }) { + if #available(iOS 16.0, *) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { endonym; Text(" "); exonym } + VStack(alignment: .leading) { endonym; exonym } + } + } else { + // less optimal because if you’re using an LTR language, RTL languages + // will read as “ ([exonym])[endonym]” (and vice versa in RTL locales) + Text("\(endonym)\(exonym)") + } + } + .tint(.primary) + .accessibilityLabel(Text(lang.label)) + }.toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Common.Controls.Actions.cancel) { + dismiss() + } + } + } + .listStyle(.plain) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) + .navigationTitle(L10n.Scene.Compose.Language.title) + .navigationBarTitleDisplayMode(.inline) + }.navigationViewStyle(.stack) + } +} + +struct SwiftUIView_Previews: PreviewProvider { + static var previews: some View { + LanguagePicker(onSelect: { _ in }) + } +}