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) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-02-28 16:00:45 +02:00
parent 6cdc1fe2b5
commit d1666345ae
15 changed files with 395 additions and 40 deletions
@@ -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
}
}
@@ -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<InputMethodManager>() ?: 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
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoKeyboardLanguageModule"]
},
"android": {
"modules": ["expo.modules.keyboardlanguage.ExpoKeyboardLanguageModule"]
}
}
+4
View File
@@ -0,0 +1,4 @@
export {
addKeyboardLanguageListener,
getCurrentKeyboardLanguage,
} from './src/index'
@@ -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
@@ -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
}
}
@@ -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<ExpoKeyboardLanguageModule>(
'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)
})
}
@@ -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() {}}
}
+30
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Web stub — keyboard language detection is not available on web.
*/
export function useKeyboardLanguage(): null {
return null
}
+44 -1
View File
@@ -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<CommonNavigatorParams, 'LanguageSettings'>
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) {
/>
</View>
</SettingsList.Group>
{IS_NATIVE && (
<>
<SettingsList.Divider />
<SettingsList.Group iconInset={false}>
<SettingsList.ItemText>
<Trans>Post language</Trans>
</SettingsList.ItemText>
<Toggle.Item
name="use_keyboard_language"
label={_(
msg`Automatically detect post language from keyboard`,
)}
value={useKbLang}
onChange={value => setUseKbLang(value)}
style={[a.w_full, a.gap_md]}>
<Text style={[a.flex_1]}>
<Trans>
Automatically detect post language from keyboard
</Trans>
</Text>
<Toggle.Platform />
</Toggle.Item>
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
a.mt_sm,
]}>
<Trans>
Experimental. When enabled, the composer will default to
your keyboard language.
</Trans>
</Text>
</SettingsList.Group>
</>
)}
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
+20 -13
View File
@@ -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}))
+6
View File
@@ -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.
*/
+49 -5
View File
@@ -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({
<PostLanguageSelect
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
onSelectAutomatic={onSelectAutomatic}
keyboardLanguage={keyboardLanguage}
isAutoMode={isAutoMode}
autoLangEnabled={autoLangEnabled}
/>
<CharProgress
count={post.shortenedGraphemeLength}
@@ -21,9 +21,17 @@ import {Text} from '#/components/Typography'
export function PostLanguageSelect({
currentLanguages: currentLanguagesProp,
onSelectLanguage,
onSelectAutomatic,
keyboardLanguage,
isAutoMode,
autoLangEnabled,
}: {
currentLanguages?: string[]
onSelectLanguage?: (language: string) => 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 (
<>
<LanguageBtn onPress={languageDialogControl.open} />
<LanguageSelectDialog
titleText={<Trans>Choose post languages</Trans>}
subtitleText={
<Trans>Select up to 3 languages used in this post</Trans>
}
control={languageDialogControl}
currentLanguages={currentLanguages}
onSelectLanguages={onSelectLanguages}
maxLanguages={3}
/>
</>
)
}
const showAutoItem = !!autoLangEnabled && !!keyboardLanguage
return (
<>
@@ -77,6 +66,19 @@ export function PostLanguageSelect({
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{showAutoItem && (
<Menu.Item
label={_(msg`Automatic`)}
onPress={() => onSelectAutomatic?.()}>
<Menu.ItemText>
<Trans>Automatic</Trans>
{' ('}
{codeToLanguageName(keyboardLanguage, langPrefs.appLanguage)}
{')'}
</Menu.ItemText>
<Menu.ItemRadio selected={!!isAutoMode} />
</Menu.Item>
)}
{dedupedHistory.map(historyItem => {
const langCodes = historyItem.split(',')
const langName = langCodes
@@ -92,7 +94,9 @@ export function PostLanguageSelect({
}}>
<Menu.ItemText>{langName}</Menu.ItemText>
<Menu.ItemRadio
selected={currentLanguages.includes(historyItem)}
selected={
!isAutoMode && currentLanguages.includes(historyItem)
}
/>
</Menu.Item>
)