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>
This commit is contained in:
parent
6685470652
commit
0a9689c67f
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -87,7 +87,8 @@ extension SendPostIntentHandler: SendPostIntentHandling {
|
|||
inReplyToID: nil,
|
||||
sensitive: nil,
|
||||
spoilerText: nil,
|
||||
visibility: visibility
|
||||
visibility: visibility,
|
||||
language: nil
|
||||
),
|
||||
authenticationBox: authenticationBox
|
||||
)
|
||||
|
|
|
@ -193,6 +193,7 @@
|
|||
<attribute name="preferredStaticEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="preferredTrueBlackDarkMode" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="preferredUsingDefaultBrowser" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="rawRecentLanguages" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
|
||||
|
|
|
@ -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<Subscription>?
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Bool> {
|
||||
Binding {
|
||||
code == viewModel.language
|
||||
} set: { newValue in
|
||||
if newValue {
|
||||
viewModel.language = code
|
||||
}
|
||||
didChangeLanguage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.Status.Visibility {
|
||||
|
|
|
@ -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))" : "")
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue