From a6d64282c050e67a470178adbd9918477d82ba57 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 5 May 2023 10:13:20 -0400 Subject: [PATCH] Add language picker to Compose screen Closes #236 --- .../Sources/ComposeUI/API/PostService.swift | 2 +- .../Sources/ComposeUI/ComposeInput.swift | 2 + .../Controllers/ComposeController.swift | 15 ++ .../Controllers/ToolbarController.swift | 5 + .../Sources/ComposeUI/CoreData/Draft.swift | 1 + .../Drafts.xcdatamodel/contents | 1 + .../ComposeUI/Views/EmojiTextField.swift | 4 + .../ComposeUI/Views/LanguagePicker.swift | 192 ++++++++++++++++++ .../ComposeUI/Views/MainTextView.swift | 4 + .../InstanceFeatures/InstanceFeatures.swift | 46 +++++ .../Pachyderm/Sources/Pachyderm/Client.swift | 2 +- 11 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index e89f9216..015f1935 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -48,7 +48,7 @@ class PostService: ObservableObject { sensitive: sensitive, spoilerText: contentWarning, visibility: draft.visibility, - language: nil, + language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, pollOptions: draft.poll?.pollOptions.map(\.text), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollMultiple: draft.poll?.multiple, diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift index a5f47aaf..35a180e8 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -7,9 +7,11 @@ import Foundation import Combine +import UIKit protocol ComposeInput: AnyObject, ObservableObject { var toolbarElements: [ToolbarElement] { get } + var textInputMode: UITextInputMode? { get } var autocompleteState: AutocompleteState? { get } var autocompleteStatePublisher: Published.Publisher { get } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 6f950fe5..fab135b5 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -50,6 +50,7 @@ public final class ComposeController: ViewController { @Published var poster: PostService? @Published var postError: PostService.Error? @Published public private(set) var didPostSuccessfully = false + @Published var hasChangedLanguageSelection = false var isPosting: Bool { poster != nil @@ -107,6 +108,10 @@ public final class ComposeController: ViewController { self.autocompleteController = AutocompleteController(parent: self) self.toolbarController = ToolbarController(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 { @@ -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 { @OptionalObservedObject var poster: PostService? @EnvironmentObject var controller: ComposeController diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift index a5002d3b..110e665e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -79,6 +79,11 @@ class ToolbarController: ViewController { } Spacer() + + if #available(iOS 16.0, *), + composeController.mastodonController.instanceFeatures.createStatusWithLanguage { + LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection) + } } .padding(.horizontal, 16) .frame(minWidth: minWidth) diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift index 19501344..e1fbf85a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Draft.swift @@ -26,6 +26,7 @@ public class Draft: NSManagedObject, Identifiable { @NSManaged public var id: UUID @NSManaged public var initialText: String @NSManaged public var inReplyToID: String? + @NSManaged public var language: String? // ISO 639 language code @NSManaged public var lastModified: Date @NSManaged public var localOnly: Bool @NSManaged public var text: String diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents index 667bed3f..5ed4d3fe 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/Drafts.xcdatamodeld/Drafts.xcdatamodel/contents @@ -7,6 +7,7 @@ + diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift index b5a0809c..dd02ef8c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/EmojiTextField.swift @@ -123,6 +123,10 @@ struct EmojiTextField: UIViewRepresentable { var toolbarElements: [ToolbarElement] { [.emojiPicker] } + var textInputMode: UITextInputMode? { + textField?.textInputMode + } + func applyFormat(_ format: StatusFormat) { } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift new file mode 100644 index 00000000..aa9a8f99 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift @@ -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[.. { + 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 + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift index f44c3d59..df47f4ff 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/MainTextView.swift @@ -256,6 +256,10 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable { [.emojiPicker, .formattingButtons] } + var textInputMode: UITextInputMode? { + textView?.textInputMode + } + func autocomplete(with string: String) { textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState) } diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 6ddf11dc..48d0580d 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -111,6 +111,10 @@ public class InstanceFeatures: ObservableObject { instanceType.isMastodon } + public var createStatusWithLanguage: Bool { + instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil)) + } + 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 { if case .pleroma(_) = self { return true @@ -206,17 +219,50 @@ extension InstanceFeatures { return false } } + + func isPleroma(_ subtype: PleromaType) -> Bool { + if case .pleroma(let t) = self, + t.equalsIgnoreVersion(subtype) { + return true + } else { + return false + } + } } enum MastodonType { case vanilla case hometown(Version?) 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 { case vanilla(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 + } + } } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 807f4a3c..f1808ec3 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -385,7 +385,7 @@ public class Client { sensitive: Bool? = nil, spoilerText: String? = nil, visibility: Visibility? = nil, - language: String? = nil, + language: String? = nil, // language supported by mastodon and akkoma pollOptions: [String]? = nil, pollExpiresIn: Int? = nil, pollMultiple: Bool? = nil,