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:
Jed Fox 2023-01-23 19:50:10 -05:00 committed by GitHub
parent 6685470652
commit 0a9689c67f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 355 additions and 6 deletions

View File

@ -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": {

View File

@ -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": {

View File

@ -87,7 +87,8 @@ extension SendPostIntentHandler: SendPostIntentHandling {
inReplyToID: nil,
sensitive: nil,
spoilerText: nil,
visibility: visibility
visibility: visibility,
language: nil
),
authenticationBox: authenticationBox
)

View File

@ -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"/>

View File

@ -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>?
}

View File

@ -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")

View File

@ -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";

View File

@ -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

View File

@ -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 dont 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()
}
}

View File

@ -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()
}

View File

@ -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(

View File

@ -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("Languages 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("Languages 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("Languages 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]]"
}
}
}

View File

@ -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 doesnt 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 its 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 {

View File

@ -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))" : "")
}
}

View File

@ -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) {
// dont 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 youre 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 })
}
}