Add language picker to Compose screen

Closes #236
This commit is contained in:
Shadowfacts 2023-05-05 10:13:20 -04:00
parent 24fb0e0e7b
commit a6d64282c0
11 changed files with 272 additions and 2 deletions

View File

@ -48,7 +48,7 @@ class PostService: ObservableObject {
sensitive: sensitive, sensitive: sensitive,
spoilerText: contentWarning, spoilerText: contentWarning,
visibility: draft.visibility, visibility: draft.visibility,
language: nil, language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text), pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple, pollMultiple: draft.poll?.multiple,

View File

@ -7,9 +7,11 @@
import Foundation import Foundation
import Combine import Combine
import UIKit
protocol ComposeInput: AnyObject, ObservableObject { protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get } var toolbarElements: [ToolbarElement] { get }
var textInputMode: UITextInputMode? { get }
var autocompleteState: AutocompleteState? { get } var autocompleteState: AutocompleteState? { get }
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get } var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }

View File

@ -50,6 +50,7 @@ public final class ComposeController: ViewController {
@Published var poster: PostService? @Published var poster: PostService?
@Published var postError: PostService.Error? @Published var postError: PostService.Error?
@Published public private(set) var didPostSuccessfully = false @Published public private(set) var didPostSuccessfully = false
@Published var hasChangedLanguageSelection = false
var isPosting: Bool { var isPosting: Bool {
poster != nil poster != nil
@ -107,6 +108,10 @@ public final class ComposeController: ViewController {
self.autocompleteController = AutocompleteController(parent: self) self.autocompleteController = AutocompleteController(parent: self)
self.toolbarController = ToolbarController(parent: self) self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self) self.attachmentsListController = AttachmentsListController(parent: self)
if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
}
} }
public var view: some View { public var view: some View {
@ -225,6 +230,16 @@ public final class ComposeController: ViewController {
} }
} }
@available(iOS 16.0, *)
@objc private func currentInputModeChanged() {
guard let mode = currentInput?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode),
!hasChangedLanguageSelection && !draft.hasContent else {
return
}
draft.language = code.identifier
}
struct ComposeView: View { struct ComposeView: View {
@OptionalObservedObject var poster: PostService? @OptionalObservedObject var poster: PostService?
@EnvironmentObject var controller: ComposeController @EnvironmentObject var controller: ComposeController

View File

@ -79,6 +79,11 @@ class ToolbarController: ViewController {
} }
Spacer() Spacer()
if #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(minWidth: minWidth) .frame(minWidth: minWidth)

View File

@ -26,6 +26,7 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var id: UUID @NSManaged public var id: UUID
@NSManaged public var initialText: String @NSManaged public var initialText: String
@NSManaged public var inReplyToID: String? @NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date @NSManaged public var lastModified: Date
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var text: String @NSManaged public var text: String

View File

@ -7,6 +7,7 @@
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialText" attributeType="String"/> <attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="text" attributeType="String" defaultValueString=""/> <attribute name="text" attributeType="String" defaultValueString=""/>

View File

@ -123,6 +123,10 @@ struct EmojiTextField: UIViewRepresentable {
var toolbarElements: [ToolbarElement] { [.emojiPicker] } var toolbarElements: [ToolbarElement] { [.emojiPicker] }
var textInputMode: UITextInputMode? {
textField?.textInputMode
}
func applyFormat(_ format: StatusFormat) { func applyFormat(_ format: StatusFormat) {
} }

View File

@ -0,0 +1,192 @@
//
// LanguagePicker.swift
// ComposeUI
//
// Created by Shadowfacts on 5/4/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct LanguagePicker: View {
@Binding var draftLanguage: String?
@Binding var hasChangedSelection: Bool
@State private var isShowingSheet = false
private var codeFromDraft: Locale.LanguageCode? {
draftLanguage.map(Locale.LanguageCode.init(_:))
}
private var codeFromActiveInputMode: Locale.LanguageCode? {
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
}
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let code = Locale.LanguageCode(String(maybeIso639Code))
if code.isISOLanguage {
return code
} else {
return nil
}
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first {
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
} else {
return nil
}
}
private var languageCode: Binding<Locale.LanguageCode> {
Binding {
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
} set: { newValue in
draftLanguage = newValue.identifier
}
}
var body: some View {
Button {
isShowingSheet = true
} label: {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
}
.accessibilityLabel("Post Language")
.sheet(isPresented: $isShowingSheet) {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
}
}
}
}
@available(iOS 16.0, *)
private struct LanguagePickerList: View {
@Binding var languageCode: Locale.LanguageCode
@Binding var hasChangedSelection: Bool
@Binding var isPresented: Bool
@State private var recentLangs: [Lang] = []
@State private var langs: [Lang] = []
@State private var filteredLangs: [Lang]?
@State private var query = ""
private var defaults: UserDefaults {
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
}
private var recentIdentifiers: [String] {
get {
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
}
nonmutating set {
defaults.set(newValue, forKey: "LanguagePickerRecents")
}
}
var body: some View {
List {
Section {
ForEach(recentLangs) { lang in
button(for: lang)
}
} header: {
Text("Recently Used")
}
Section {
ForEach(filteredLangs ?? langs) { lang in
button(for: lang)
}
} header: {
Text("All Languages")
}
}
.searchable(text: $query)
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isPresented = false
}
}
}
.onAppear {
// make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode)
recentLangs = recents
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}
.onChange(of: query) { newValue in
if newValue.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
}
}
}
}
@discardableResult
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
var recents = recentIdentifiers
if !recents.contains(languageCode.identifier) {
recents.insert(languageCode.identifier, at: 0)
if recents.count > 5 {
recents = Array(recents[..<5])
}
recentIdentifiers = recents
}
return recents
}
private func button(for lang: Lang) -> some View {
Button {
languageCode = lang.code
hasChangedSelection = true
isPresented = false
addRecentLang(lang.code)
} label: {
HStack {
Text(lang.name)
Spacer()
if lang.code == languageCode {
Image(systemName: "checkmark")
}
}
}
}
struct Lang: Identifiable {
let code: Locale.LanguageCode
let name: String
var id: String {
code.identifier
}
init(code: Locale.LanguageCode) {
self.code = code
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
}
}
}

View File

@ -256,6 +256,10 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
[.emojiPicker, .formattingButtons] [.emojiPicker, .formattingButtons]
} }
var textInputMode: UITextInputMode? {
textView?.textInputMode
}
func autocomplete(with string: String) { func autocomplete(with string: String) {
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState) textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
} }

View File

@ -111,6 +111,10 @@ public class InstanceFeatures: ObservableObject {
instanceType.isMastodon instanceType.isMastodon
} }
public var createStatusWithLanguage: Bool {
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
}
public init() { public init() {
} }
@ -199,6 +203,15 @@ extension InstanceFeatures {
} }
} }
func isMastodon(_ subtype: MastodonType) -> Bool {
if case .mastodon(let t, _) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
var isPleroma: Bool { var isPleroma: Bool {
if case .pleroma(_) = self { if case .pleroma(_) = self {
return true return true
@ -206,17 +219,50 @@ extension InstanceFeatures {
return false return false
} }
} }
func isPleroma(_ subtype: PleromaType) -> Bool {
if case .pleroma(let t) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
} }
enum MastodonType { enum MastodonType {
case vanilla case vanilla
case hometown(Version?) case hometown(Version?)
case glitch case glitch
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
switch (self, other) {
case (.vanilla, .vanilla):
return true
case (.hometown(_), .hometown(_)):
return true
case (.glitch, .glitch):
return true
default:
return false
}
}
} }
enum PleromaType { enum PleromaType {
case vanilla(Version?) case vanilla(Version?)
case akkoma(Version?) case akkoma(Version?)
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
switch (self, other) {
case (.vanilla(_), .vanilla(_)):
return true
case (.akkoma(_), .akkoma(_)):
return true
default:
return false
}
}
} }
} }

View File

@ -385,7 +385,7 @@ public class Client {
sensitive: Bool? = nil, sensitive: Bool? = nil,
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Visibility? = nil, visibility: Visibility? = nil,
language: String? = nil, language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil, pollMultiple: Bool? = nil,