Prepend language list with recently used languages (#353)

* Add new preference entry for recently used languages

Exposes a function to keep the language array clean: no more than 3 items, starting with the most recently used iso code

* Add the preferences to the status editor ViewModel

* Add language selector handling of most recent languages

Only when the user has explicitly selected a language, when the posting was successful, add the selected language to the preferences array.

- Makes Language a local private struct for clarity
- Ensures all available languages are only fetched once
- Separates recently used, other and search result section contents using specific vars/funcs

* Copy new key in all localization files

Co-authored-by: Pascal Batty <pascal@zen.ly>
This commit is contained in:
Pascal Batty 2023-01-24 21:34:16 +01:00 committed by GitHub
parent 5b3afc72de
commit a1218e1488
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 85 additions and 22 deletions

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "Entwürfe";
"status.editor.error.upload" = "Fehler beim Hochladen";
"status.editor.language-select.navigation-title" = "Sprache auswählen";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "Bild bearbeiten";
"status.editor.media.image-description" = "Bildbeschreibung";
"status.editor.mode.edit" = "Deinen Post bearbeiten";

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "Drafts";
"status.editor.error.upload" = "Error uploading";
"status.editor.language-select.navigation-title" = "Select Language";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "Edit Image";
"status.editor.media.image-description" = "Image description";
"status.editor.mode.edit" = "Editing your post";

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "Borradores";
"status.editor.error.upload" = "Error subiendo";
"status.editor.language-select.navigation-title" = "Seleccionar idioma";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "Editar Imagen";
"status.editor.media.image-description" = "Descripción de la imagen";
"status.editor.mode.edit" = "Editando tu publicación";

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "Bozze";
"status.editor.error.upload" = "Errore durante il caricamento";
"status.editor.language-select.navigation-title" = "Scegli la lingua";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "Modifica l'immagine";
"status.editor.media.image-description" = "Descrizione dell'immagine";
"status.editor.mode.edit" = "Messaggio in modifica";

View file

@ -277,6 +277,7 @@
"status.editor.drafts.navigation-title" = "下書き";
"status.editor.error.upload" = "アップロードエラー";
"status.editor.language-select.navigation-title" = "言語設定";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "イメージの編集";
"status.editor.media.image-description" = "イメージの説明文";
"status.editor.mode.edit" = "投稿を編集する";

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "Concepten";
"status.editor.error.upload" = "Fout tijdens uploaden";
"status.editor.language-select.navigation-title" = "Taal selecteren";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "Afbeelding bewerken";
"status.editor.media.image-description" = "Omschrijving";
"status.editor.mode.edit" = "Je post bewerken";

View file

@ -281,6 +281,7 @@
"status.editor.drafts.navigation-title" = "草稿";
"status.editor.error.upload" = "上传错误";
"status.editor.language-select.navigation-title" = "选择语言";
"status.editor.language-select.recently-used" = "Recently Used";
"status.editor.media.edit-image" = "编辑图片";
"status.editor.media.image-description" = "图片描述";
"status.editor.mode.edit" = "正在编辑你的嘟文";

View file

@ -16,6 +16,7 @@ public class UserPreferences: ObservableObject {
@AppStorage("font_size_scale") public var fontSizeScale: Double = 1
@AppStorage("show_translate_button_inline") public var showTranslateButton: Bool = true
@AppStorage("is_open_ai_enabled") public var isOpenAIEnabled: Bool = true
@AppStorage("recently_used_languages") public var recentlyUsedLanguages: [String] = []
public var pushNotificationsCount: Int {
get {
@ -41,4 +42,13 @@ public class UserPreferences: ObservableObject {
guard let client, client.isAuth else { return }
serverPreferences = try? await client.get(endpoint: Accounts.preferences)
}
public func markLanguageAsSelected(isoCode: String) {
var copy = recentlyUsedLanguages
if let index = copy.firstIndex(of: isoCode) {
copy.remove(at: index)
}
copy.insert(isoCode, at: 0)
recentlyUsedLanguages = Array(copy.prefix(3))
}
}

View file

@ -107,21 +107,17 @@ struct StatusEditorAccessoryView: View {
private var languageSheetView: some View {
NavigationStack {
List {
ForEach(availableLanguages, id: \.0) { isoCode, nativeName, name in
HStack {
languageTextView(isoCode: isoCode, nativeName: nativeName, name: name)
.tag(isoCode)
Spacer()
if isoCode == viewModel.selectedLanguage {
Image(systemName: "checkmark")
if languageSearch.isEmpty {
if !recentlyUsedLanguages.isEmpty {
Section("Recently Used") {
languageSheetSection(languages: recentlyUsedLanguages)
}
}
.listRowBackground(theme.primaryBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
viewModel.selectedLanguage = isoCode
isLanguageSheetDisplayed = false
Section {
languageSheetSection(languages: otherLanguages)
}
} else {
languageSheetSection(languages: languageSearchResult(query: languageSearch))
}
}
.searchable(text: $languageSearch)
@ -137,6 +133,29 @@ struct StatusEditorAccessoryView: View {
}
}
private func languageSheetSection(languages: [Language]) -> some View {
ForEach(languages) { language in
HStack {
languageTextView(
isoCode: language.isoCode,
nativeName: language.nativeName,
name: language.localizedName
).tag(language.isoCode)
Spacer()
if language.isoCode == viewModel.selectedLanguage {
Image(systemName: "checkmark")
}
}
.listRowBackground(theme.primaryBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
viewModel.selectedLanguage = language.isoCode
viewModel.hasExplicitlySelectedLanguage = true
isLanguageSheetDisplayed = false
}
}
}
private var draftsSheetView: some View {
NavigationStack {
List {
@ -206,22 +225,43 @@ struct StatusEditorAccessoryView: View {
.font(.scaledCallout)
}
private var availableLanguages: [(String, String?, String?)] {
private struct Language: Identifiable, Equatable {
var id: String { isoCode }
let isoCode: String
let nativeName: String?
let localizedName: String?
}
private let allAvailableLanguages: [Language] =
Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier.count == 2 } // Mastodon only supports ISO 639-1 (two-letter) codes
.map { lang in
let nativeLocale = Locale(languageComponents: Locale.Language.Components(languageCode: lang))
return (
lang.identifier,
nativeLocale.localizedString(forLanguageCode: lang.identifier),
Locale.current.localizedString(forLanguageCode: lang.identifier)
return Language(
isoCode: lang.identifier,
nativeName: nativeLocale.localizedString(forLanguageCode: lang.identifier)?.capitalized,
localizedName: Locale.current.localizedString(forLanguageCode: lang.identifier)?.localizedCapitalized
)
}
.filter { _, nativeLocale, _ in
guard !languageSearch.isEmpty else {
return true
}
return nativeLocale?.lowercased().hasPrefix(languageSearch.lowercased()) == true
private var recentlyUsedLanguages: [Language] {
preferences.recentlyUsedLanguages.compactMap { isoCode in
allAvailableLanguages.first { $0.isoCode == isoCode }
}
}
private var otherLanguages: [Language] {
allAvailableLanguages.filter { !preferences.recentlyUsedLanguages.contains($0.isoCode) }
}
private func languageSearchResult(query: String) -> [Language] {
allAvailableLanguages.filter { language in
guard !languageSearch.isEmpty else {
return true
}
return language.nativeName?.lowercased().hasPrefix(query.lowercased()) == true
|| language.localizedName?.lowercased().hasPrefix(query.lowercased()) == true
}
}
}

View file

@ -74,6 +74,7 @@ public struct StatusEditorView: View {
viewModel.client = client
viewModel.currentAccount = currentAccount.account
viewModel.theme = theme
viewModel.preferences = preferences
viewModel.prepareStatusText()
if !client.isAuth {
dismiss()

View file

@ -13,6 +13,7 @@ public class StatusEditorViewModel: ObservableObject {
var client: Client?
var currentAccount: Account?
var theme: Theme?
var preferences: UserPreferences?
@Published var statusText = NSMutableAttributedString(string: "") {
didSet {
@ -87,6 +88,7 @@ public class StatusEditorViewModel: ObservableObject {
@Published var mentionsSuggestions: [Account] = []
@Published var tagsSuggestions: [Tag] = []
@Published var selectedLanguage: String?
var hasExplicitlySelectedLanguage: Bool = false
private var currentSuggestionRange: NSRange?
private var embeddedStatusURL: URL? {
@ -136,6 +138,9 @@ public class StatusEditorViewModel: ObservableObject {
postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data))
}
generator.notificationOccurred(.success)
if hasExplicitlySelectedLanguage, let selectedLanguage {
preferences?.markLanguageAsSelected(isoCode: selectedLanguage)
}
isPosting = false
return postStatus
} catch let error {