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() {}}
}