Files
bsky-social-app/src/lib/translation/index.tsx
T
2026-04-01 13:39:05 -07:00

338 lines
9.9 KiB
TypeScript

import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {onTranslateTask} from '@bsky.app/expo-translate-text'
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
import {useLingui} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {codeToLanguageName} from '#/locale/helpers'
import {logger} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED} from '#/env'
import {Context} from './context'
import {
type ContextType,
type TranslationFunctionParams,
type TranslationOptions,
type TranslationState,
} from './types'
import {guessLanguage} from './utils'
export * from './types'
export * from './utils'
const E_SAME_AS_SOURCE_LANGUAGE =
'Translation result is the same as the source text.'
const E_EMPTY_RESULT = 'Translation result is empty.'
const E_INVALID_SOURCE_LANGUAGE = 'Invalid source language'
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
* Uses a lazy import to avoid crashing if the native module isn't linked into
* the current build.
*/
async function attemptTranslation(
input: string,
targetLangCodeOriginal: string,
sourceLangCodeOriginal?: string, // Auto-detects if not provided
): Promise<{
translatedText: string
targetLanguage: TranslationTaskResult['targetLanguage']
sourceLanguage: TranslationTaskResult['sourceLanguage']
}> {
// Note that Android only supports two-character language codes and will fail
// on other input.
// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage
let targetLangCode = IS_ANDROID
? targetLangCodeOriginal.split('-')[0]
: targetLangCodeOriginal
const sourceLangCode = IS_ANDROID
? sourceLangCodeOriginal?.split('-')[0]
: sourceLangCodeOriginal
// Special cases for regional languages since iOS differentiates and missing
// language packs must be downloaded and installed.
if (IS_IOS) {
const deviceLocales = getLocales()
const primaryLanguageTag = deviceLocales[0]?.languageTag
switch (targetLangCodeOriginal) {
case 'en': // en-US, en-GB
case 'es': // es-419, es-ES
case 'pt': // pt-BR, pt-PT
case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW
if (
primaryLanguageTag &&
primaryLanguageTag.startsWith(targetLangCodeOriginal)
) {
targetLangCode = primaryLanguageTag
}
break
}
}
const result = await onTranslateTask({
input,
targetLangCode,
sourceLangCode,
})
// Since `input` is always a string, the result should always be a string.
const translatedText =
typeof result.translatedTexts === 'string' ? result.translatedTexts : ''
if (translatedText === input) {
throw new Error(E_SAME_AS_SOURCE_LANGUAGE)
}
if (translatedText === '') {
throw new Error(E_EMPTY_RESULT)
}
return {
translatedText,
targetLanguage: result.targetLanguage,
sourceLanguage:
result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language
}
}
/**
* Native translation hook. Attempts on-device translation using Apple
* Translation (iOS 18+) or Google ML Kit (Android).
*
* Falls back to Google Translate URL if the language pack is unavailable.
*
* Web uses index.web.ts which always opens Google Translate.
*/
export function useTranslate({
key,
forceGoogleTranslate = false,
}: TranslationOptions) {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslate must be used within a TranslateOnDeviceProvider',
)
}
useFocusEffect(
useCallback(() => {
const cleanup = context.acquireTranslation(key)
return cleanup
}, [key, context]),
)
const translate = useCallback(
async (params: TranslationFunctionParams) => {
return context.translate(
{
...params,
},
{
key,
forceGoogleTranslate,
},
)
},
[context, forceGoogleTranslate, key],
)
const clearTranslation = useCallback(
() => context.clearTranslation(key),
[context, key],
)
return useMemo(
() => ({
translationState: context.translationState[key] ?? {
status: 'idle',
},
translate,
clearTranslation,
}),
[clearTranslation, context.translationState, key, translate],
)
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const [translationState, setTranslationState] = useState<
Record<string, TranslationState>
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const ax = useAnalytics()
const langPrefs = useLanguagePrefs()
const {t: l} = useLingui()
const googleTranslate = useGoogleTranslate()
useEffect(() => {
setTranslationState(prev => {
const keysToDelete: string[] = []
for (const key of Object.keys(prev)) {
if ((refCounts[key] ?? 0) <= 0) {
keysToDelete.push(key)
}
}
if (keysToDelete.length > 0) {
const newState = {...prev}
keysToDelete.forEach(key => {
delete newState[key]
})
return newState
}
return prev
})
}, [refCounts])
const acquireTranslation = useCallback((key: string) => {
setRefCounts(prev => ({
...prev,
[key]: (prev[key] ?? 0) + 1,
}))
return () => {
setRefCounts(prev => {
const newCount = (prev[key] ?? 1) - 1
if (newCount <= 0) {
const {[key]: _, ...rest} = prev
return rest
}
return {...prev, [key]: newCount}
})
}
}, [])
const clearTranslation = useCallback((key: string) => {
if (!IS_ANDROID) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
setTranslationState(prev => {
delete prev[key]
return {...prev}
})
}, [])
const translate = useCallback<ContextType['translate']>(
async (
{
text,
expectedTargetLanguage,
expectedSourceLanguage,
possibleSourceLanguages,
forceGoogleTranslate: forceGoogleTranslateOverride,
},
{key, forceGoogleTranslate},
) => {
const shouldForceGoogleTranslate = Boolean(
forceGoogleTranslateOverride ?? forceGoogleTranslate,
)
ax.metric('translate', {
os: Platform.OS,
possibleSourceLanguages,
expectedTargetLanguage: expectedTargetLanguage,
textLength: text.length,
googleTranslate: shouldForceGoogleTranslate,
})
if (shouldForceGoogleTranslate || !IS_TRANSLATION_SUPPORTED) {
await googleTranslate(
text,
expectedTargetLanguage,
expectedSourceLanguage,
)
return
}
if (!IS_ANDROID) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
setTranslationState(prev => ({
...prev,
[key]: {status: 'loading'},
}))
try {
const result = await attemptTranslation(
text,
expectedTargetLanguage,
expectedSourceLanguage,
)
ax.metric('translate:result', {
success: true,
os: Platform.OS,
possibleSourceLanguages,
expectedSourceLanguage: expectedSourceLanguage ?? null,
expectedTargetLanguage,
resultSourceLanguage: result.sourceLanguage,
resultTargetLanguage: result.targetLanguage,
textLength: text.length,
})
if (!IS_ANDROID) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
setTranslationState(prev => ({
...prev,
[key]: {
status: 'success',
translatedText: result.translatedText,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
postLanguages: possibleSourceLanguages,
},
}))
} catch (err) {
const e = err as Error
logger.error('Failed to translate text on device', {safeMessage: e})
// On-device translation failed (language pack missing or user
// dismissed the download prompt).
ax.metric('translate:result', {
success: false,
os: Platform.OS,
possibleSourceLanguages,
expectedSourceLanguage: expectedSourceLanguage ?? null,
expectedTargetLanguage,
resultSourceLanguage: null,
resultTargetLanguage: null,
textLength: text.length,
})
let errorMessage = l`Device failed to translate :(`
if (e.message === E_SAME_AS_SOURCE_LANGUAGE) {
errorMessage = l`Translation to the same language is unavailable on your device.`
}
if (e.message === E_EMPTY_RESULT) {
errorMessage = l`No translation received from your device.`
}
if (
expectedSourceLanguage &&
e.message.includes(E_INVALID_SOURCE_LANGUAGE)
) {
errorMessage = l`${codeToLanguageName(
expectedSourceLanguage,
langPrefs.appLanguage,
)} is not supported by your device.`
}
if (!IS_ANDROID) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
setTranslationState(prev => ({
...prev,
[key]: {status: 'error', message: errorMessage},
}))
}
},
[ax, googleTranslate, l, langPrefs.appLanguage],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[acquireTranslation, clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}