From d1666345ae34880305150d036951a4b893e5922e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sat, 28 Feb 2026 16:00:45 +0200 Subject: [PATCH] auto-detect composer language from keyboard adds a new expo-keyboard-language native module that reads the current keyboard language on iOS (via first responder's textInputMode) and Android (via InputMethodManager). iOS also gets real-time updates when the keyboard language changes. users opt in via Settings > Languages > "Automatically detect post language from keyboard" (native only, marked experimental). when enabled, the composer language tracks the keyboard language. users can override per-session by selecting a language manually, and re-enable auto mode via the "Automatic" item in the language menu. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../android/build.gradle | 39 ++++++++++ .../ExpoKeyboardLanguageModule.kt | 31 ++++++++ .../expo-module.config.json | 9 +++ modules/expo-keyboard-language/index.ts | 4 + .../ios/ExpoKeyboardLanguage.podspec | 20 +++++ .../ios/ExpoKeyboardLanguageModule.swift | 75 +++++++++++++++++++ .../src/index.native.ts | 26 +++++++ modules/expo-keyboard-language/src/index.ts | 11 +++ src/lib/hooks/useKeyboardLanguage.ts | 30 ++++++++ src/lib/hooks/useKeyboardLanguage.web.ts | 6 ++ src/screens/Settings/LanguageSettings.tsx | 45 ++++++++++- src/state/preferences/languages.tsx | 33 ++++---- src/storage/schema.ts | 6 ++ src/view/com/composer/Composer.tsx | 54 +++++++++++-- .../select-language/PostLanguageSelect.tsx | 46 ++++++------ 15 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 modules/expo-keyboard-language/android/build.gradle create mode 100644 modules/expo-keyboard-language/android/src/main/java/expo/modules/keyboardlanguage/ExpoKeyboardLanguageModule.kt create mode 100644 modules/expo-keyboard-language/expo-module.config.json create mode 100644 modules/expo-keyboard-language/index.ts create mode 100644 modules/expo-keyboard-language/ios/ExpoKeyboardLanguage.podspec create mode 100644 modules/expo-keyboard-language/ios/ExpoKeyboardLanguageModule.swift create mode 100644 modules/expo-keyboard-language/src/index.native.ts create mode 100644 modules/expo-keyboard-language/src/index.ts create mode 100644 src/lib/hooks/useKeyboardLanguage.ts create mode 100644 src/lib/hooks/useKeyboardLanguage.web.ts diff --git a/modules/expo-keyboard-language/android/build.gradle b/modules/expo-keyboard-language/android/build.gradle new file mode 100644 index 0000000000..76d1a4a344 --- /dev/null +++ b/modules/expo-keyboard-language/android/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.keyboardlanguage' +version = '1.0.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +android { + namespace "expo.modules.keyboardlanguage" + defaultConfig { + versionCode 1 + versionName "1.0.0" + } + lintOptions { + abortOnError false + } +} diff --git a/modules/expo-keyboard-language/android/src/main/java/expo/modules/keyboardlanguage/ExpoKeyboardLanguageModule.kt b/modules/expo-keyboard-language/android/src/main/java/expo/modules/keyboardlanguage/ExpoKeyboardLanguageModule.kt new file mode 100644 index 0000000000..cb173e3606 --- /dev/null +++ b/modules/expo-keyboard-language/android/src/main/java/expo/modules/keyboardlanguage/ExpoKeyboardLanguageModule.kt @@ -0,0 +1,31 @@ +package expo.modules.keyboardlanguage + +import android.os.Build +import android.view.inputmethod.InputMethodManager +import androidx.core.content.getSystemService +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class ExpoKeyboardLanguageModule : Module() { + override fun definition() = + ModuleDefinition { + Name("ExpoKeyboardLanguage") + + Function("getCurrentKeyboardLanguage") { + val context = appContext.reactContext ?: return@Function null + val imm = context.getSystemService() ?: return@Function null + val subtype = imm.currentInputMethodSubtype ?: return@Function null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val tag = subtype.languageTag + if (tag.isNotEmpty()) return@Function tag + } + + @Suppress("DEPRECATION") + val locale = subtype.locale + if (locale.isNotEmpty()) return@Function locale + + return@Function null + } + } +} diff --git a/modules/expo-keyboard-language/expo-module.config.json b/modules/expo-keyboard-language/expo-module.config.json new file mode 100644 index 0000000000..8fffb703d0 --- /dev/null +++ b/modules/expo-keyboard-language/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["ExpoKeyboardLanguageModule"] + }, + "android": { + "modules": ["expo.modules.keyboardlanguage.ExpoKeyboardLanguageModule"] + } +} diff --git a/modules/expo-keyboard-language/index.ts b/modules/expo-keyboard-language/index.ts new file mode 100644 index 0000000000..51f71b6531 --- /dev/null +++ b/modules/expo-keyboard-language/index.ts @@ -0,0 +1,4 @@ +export { + addKeyboardLanguageListener, + getCurrentKeyboardLanguage, +} from './src/index' diff --git a/modules/expo-keyboard-language/ios/ExpoKeyboardLanguage.podspec b/modules/expo-keyboard-language/ios/ExpoKeyboardLanguage.podspec new file mode 100644 index 0000000000..2019a14051 --- /dev/null +++ b/modules/expo-keyboard-language/ios/ExpoKeyboardLanguage.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'ExpoKeyboardLanguage' + s.version = '1.0.0' + s.summary = 'Expo module to detect the current keyboard language' + s.description = 'Expo module to detect the current keyboard language' + s.author = '' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '13.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-keyboard-language/ios/ExpoKeyboardLanguageModule.swift b/modules/expo-keyboard-language/ios/ExpoKeyboardLanguageModule.swift new file mode 100644 index 0000000000..07d2cbff11 --- /dev/null +++ b/modules/expo-keyboard-language/ios/ExpoKeyboardLanguageModule.swift @@ -0,0 +1,75 @@ +import ExpoModulesCore + +public class ExpoKeyboardLanguageModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoKeyboardLanguage") + + Events("onKeyboardLanguageChange") + + Function("getCurrentKeyboardLanguage") { + return self.currentKeyboardLanguage() + } + + OnStartObserving { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onInputModeOrResponderChange), + name: UITextInputMode.currentInputModeDidChangeNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onInputModeOrResponderChange), + name: UIResponder.keyboardDidShowNotification, + object: nil + ) + } + + OnStopObserving { + NotificationCenter.default.removeObserver( + self, + name: UITextInputMode.currentInputModeDidChangeNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: UIResponder.keyboardDidShowNotification, + object: nil + ) + } + } + + private var lastLanguage: String? + + @objc + private func onInputModeOrResponderChange() { + let language = currentKeyboardLanguage() + if language != lastLanguage { + lastLanguage = language + sendEvent("onKeyboardLanguageChange", [ + "language": language as Any + ]) + } + } + + private func currentKeyboardLanguage() -> String? { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + return keyWindow?.findFirstResponder()?.textInputMode?.primaryLanguage + } +} + +private extension UIView { + func findFirstResponder() -> UIView? { + if isFirstResponder { return self } + for subview in subviews { + if let found = subview.findFirstResponder() { + return found + } + } + return nil + } +} diff --git a/modules/expo-keyboard-language/src/index.native.ts b/modules/expo-keyboard-language/src/index.native.ts new file mode 100644 index 0000000000..e7018bb66b --- /dev/null +++ b/modules/expo-keyboard-language/src/index.native.ts @@ -0,0 +1,26 @@ +import {type EventSubscription} from 'expo-modules-core' +import {requireNativeModule} from 'expo-modules-core' + +type ExpoKeyboardLanguageModule = { + getCurrentKeyboardLanguage(): string | null + addListener( + eventName: 'onKeyboardLanguageChange', + listener: (event: {language: string | null}) => void, + ): EventSubscription +} + +const NativeModule = requireNativeModule( + 'ExpoKeyboardLanguage', +) + +export function getCurrentKeyboardLanguage(): string | null { + return NativeModule.getCurrentKeyboardLanguage() +} + +export function addKeyboardLanguageListener( + cb: (language: string | null) => void, +): EventSubscription { + return NativeModule.addListener('onKeyboardLanguageChange', event => { + cb(event.language) + }) +} diff --git a/modules/expo-keyboard-language/src/index.ts b/modules/expo-keyboard-language/src/index.ts new file mode 100644 index 0000000000..ca17ba50c0 --- /dev/null +++ b/modules/expo-keyboard-language/src/index.ts @@ -0,0 +1,11 @@ +import {type EventSubscription} from 'expo-modules-core' + +export function getCurrentKeyboardLanguage(): string | null { + return null +} + +export function addKeyboardLanguageListener( + _cb: (language: string | null) => void, +): EventSubscription { + return {remove() {}} +} diff --git a/src/lib/hooks/useKeyboardLanguage.ts b/src/lib/hooks/useKeyboardLanguage.ts new file mode 100644 index 0000000000..8597c2ab37 --- /dev/null +++ b/src/lib/hooks/useKeyboardLanguage.ts @@ -0,0 +1,30 @@ +import {useEffect, useState} from 'react' + +import { + addKeyboardLanguageListener, + getCurrentKeyboardLanguage, +} from '../../../modules/expo-keyboard-language' + +function toBcp2(tag: string | null): string | null { + if (!tag) return null + return tag.split('-')[0].toLowerCase() +} + +/** + * Returns the current keyboard language as a 2-letter code (e.g. "en"), + * updating in real-time on iOS. + */ +export function useKeyboardLanguage(): string | null { + const [language, setLanguage] = useState(() => + toBcp2(getCurrentKeyboardLanguage()), + ) + + useEffect(() => { + const sub = addKeyboardLanguageListener(lang => { + setLanguage(toBcp2(lang)) + }) + return () => sub.remove() + }, []) + + return language +} diff --git a/src/lib/hooks/useKeyboardLanguage.web.ts b/src/lib/hooks/useKeyboardLanguage.web.ts new file mode 100644 index 0000000000..da9b1bd316 --- /dev/null +++ b/src/lib/hooks/useKeyboardLanguage.web.ts @@ -0,0 +1,6 @@ +/** + * Web stub — keyboard language detection is not available on web. + */ +export function useKeyboardLanguage(): null { + return null +} diff --git a/src/screens/Settings/LanguageSettings.tsx b/src/screens/Settings/LanguageSettings.tsx index 9ab8590ff7..1b65aa9b9f 100644 --- a/src/screens/Settings/LanguageSettings.tsx +++ b/src/screens/Settings/LanguageSettings.tsx @@ -11,7 +11,7 @@ import { import {sanitizeAppLanguageSetting} from '#/locale/helpers' import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' -import {atoms as a, web} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' @@ -21,6 +21,8 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/ import * as Layout from '#/components/Layout' import * as Select from '#/components/Select' import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' +import {device, useStorage} from '#/storage' import * as SettingsList from './components/SettingsList' const DEDUPED_LANGUAGES = LANGUAGES.filter( @@ -31,8 +33,12 @@ const DEDUPED_LANGUAGES = LANGUAGES.filter( type Props = NativeStackScreenProps export function LanguageSettingsScreen({}: Props) { const {_} = useLingui() + const t = useTheme() const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() + const [useKbLang = false, setUseKbLang] = useStorage(device, [ + 'useKeyboardLanguage', + ]) const contentLanguagePrefsControl = useDialogControl() @@ -234,6 +240,43 @@ export function LanguageSettingsScreen({}: Props) { /> + {IS_NATIVE && ( + <> + + + + Post language + + setUseKbLang(value)} + style={[a.w_full, a.gap_md]}> + + + Automatically detect post language from keyboard + + + + + + + Experimental. When enabled, the composer will default to + your keyboard language. + + + + + )} diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx index fe7dc2105d..3e2abb3a63 100644 --- a/src/state/preferences/languages.tsx +++ b/src/state/preferences/languages.tsx @@ -12,7 +12,7 @@ type ApiContext = { setPrimaryLanguage: (code2: string) => void setPostLanguage: (commaSeparatedLangCodes: string) => void setContentLanguages: (code2s: string[]) => void - savePostLanguageToHistory: () => void + savePostLanguageToHistory: (resolvedLanguage?: string) => void setAppLanguage: (code2: AppLanguage) => void } @@ -61,21 +61,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { /** * Saves whatever language codes are currently selected into a history array, * which is then used to populate the language selector menu. + * + * If `resolvedLanguage` is provided (e.g. from auto-detected keyboard + * language), it will be saved to history instead of the current + * `postLanguage` preference. */ - savePostLanguageToHistory() { + savePostLanguageToHistory(resolvedLanguage?: string) { // filter out duplicate `this.postLanguage` if exists, and prepend // value to start of array - setStateWrapped(s => ({ - ...s, - postLanguageHistory: [s.postLanguage] - .concat( - s.postLanguageHistory.filter( - commaSeparatedLangCodes => - commaSeparatedLangCodes !== s.postLanguage, - ), - ) - .slice(0, 6), - })) + setStateWrapped(s => { + const langToSave = resolvedLanguage ?? s.postLanguage + return { + ...s, + postLanguageHistory: [langToSave] + .concat( + s.postLanguageHistory.filter( + commaSeparatedLangCodes => + commaSeparatedLangCodes !== langToSave, + ), + ) + .slice(0, 6), + } + }) }, setAppLanguage(code2: AppLanguage) { setStateWrapped(s => ({...s, appLanguage: code2})) diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 2dd99ace2d..685420c142 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -61,6 +61,12 @@ export type Device = { activitySubscriptionsNudged?: boolean threadgateNudged?: boolean + /** + * Whether to automatically detect post language from the keyboard language. + * Experimental feature, opt-in. + */ + useKeyboardLanguage?: boolean + /** * Policy update overlays. New IDs are required for each new announcement. */ diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5bde2c1461..1a5b420433 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -70,6 +70,7 @@ import { type SupportedMimeTypes, } from '#/lib/constants' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' +import {useKeyboardLanguage} from '#/lib/hooks/useKeyboardLanguage' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' @@ -134,6 +135,7 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' +import {device, useStorage} from '#/storage' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { draftToComposerPosts, @@ -243,26 +245,52 @@ export const ComposePost = ({ replyTo?.langs || [], ) + /** + * Automatic keyboard language detection. + */ + const keyboardLanguage = useKeyboardLanguage() + const [autoLangEnabled = false] = useStorage(device, ['useKeyboardLanguage']) + const [manualOverride, setManualOverride] = useState(false) + const isAutoMode = !!autoLangEnabled && !manualOverride + /** * The currently selected languages of the post. Prefer local temporary - * language suggestion over global lang prefs, if available. + * language suggestion, then auto-detected keyboard language, then global + * lang prefs. */ const currentLanguages = useMemo( () => acceptedLanguageSuggestion ? [acceptedLanguageSuggestion] - : toPostLanguages(langPrefs.postLanguage), - [acceptedLanguageSuggestion, langPrefs.postLanguage], + : isAutoMode && keyboardLanguage + ? [keyboardLanguage] + : toPostLanguages(langPrefs.postLanguage), + [ + acceptedLanguageSuggestion, + isAutoMode, + keyboardLanguage, + langPrefs.postLanguage, + ], ) /** * When the user selects a language from the composer language selector, * clear any temporary language suggestions they may have selected - * previously, and any we might try to suggest to them. + * previously, and any we might try to suggest to them. Also locks out + * auto mode for this session. */ const onSelectLanguage = () => { setAcceptedLanguageSuggestion(null) setReplyToLanguages([]) + setManualOverride(true) + } + + /** + * Re-enable auto mode when the user taps "Automatic" in the language menu. + */ + const onSelectAutomatic = () => { + setAcceptedLanguageSuggestion(null) + setManualOverride(false) } const [composerState, composerDispatch] = useReducer( @@ -939,7 +967,7 @@ export const ComposePost = ({ originalLocalRefs: composerState.originalLocalRefs, }) } - setLangPrefs.savePostLanguageToHistory() + setLangPrefs.savePostLanguageToHistory(fromPostLanguages(currentLanguages)) if (initQuote) { // We want to wait for the quote count to update before we call `onPost`, which will refetch data whenAppViewReady(agent, initQuote.uri, res => { @@ -1122,6 +1150,10 @@ export const ComposePost = ({ }} currentLanguages={currentLanguages} onSelectLanguage={onSelectLanguage} + onSelectAutomatic={onSelectAutomatic} + keyboardLanguage={keyboardLanguage} + isAutoMode={isAutoMode} + autoLangEnabled={autoLangEnabled} openGallery={openGallery} /> @@ -1823,6 +1855,10 @@ function ComposerFooter({ onAddPost, currentLanguages, onSelectLanguage, + onSelectAutomatic, + keyboardLanguage, + isAutoMode, + autoLangEnabled, openGallery, }: { post: PostDraft @@ -1834,6 +1870,10 @@ function ComposerFooter({ onAddPost: () => void currentLanguages: string[] onSelectLanguage?: (language: string) => void + onSelectAutomatic?: () => void + keyboardLanguage: string | null + isAutoMode: boolean + autoLangEnabled: boolean openGallery?: boolean }) { const t = useTheme() @@ -1992,6 +2032,10 @@ function ComposerFooter({ void + onSelectAutomatic?: () => void + keyboardLanguage?: string | null + isAutoMode?: boolean + autoLangEnabled?: boolean }) { const {_} = useLingui() const langPrefs = useLanguagePrefs() @@ -46,26 +54,7 @@ export function PostLanguageSelect({ onSelectLanguage?.(langsString) } - if ( - dedupedHistory.length === 1 && - dedupedHistory[0] === langPrefs.postLanguage - ) { - return ( - <> - - Choose post languages} - subtitleText={ - Select up to 3 languages used in this post - } - control={languageDialogControl} - currentLanguages={currentLanguages} - onSelectLanguages={onSelectLanguages} - maxLanguages={3} - /> - - ) - } + const showAutoItem = !!autoLangEnabled && !!keyboardLanguage return ( <> @@ -77,6 +66,19 @@ export function PostLanguageSelect({ + {showAutoItem && ( + onSelectAutomatic?.()}> + + Automatic + {' ('} + {codeToLanguageName(keyboardLanguage, langPrefs.appLanguage)} + {')'} + + + + )} {dedupedHistory.map(historyItem => { const langCodes = historyItem.split(',') const langName = langCodes @@ -92,7 +94,9 @@ export function PostLanguageSelect({ }}> {langName} )