Refine post translation UX

This commit is contained in:
DS Boyce
2026-03-02 12:30:19 -08:00
parent 8b1c47a499
commit 701b1ed0c6
12 changed files with 474 additions and 408 deletions
+17
View File
@@ -0,0 +1,17 @@
import {createContext} from 'react'
import {type Options, type TranslationState} from './types'
export const Context = createContext<{
translationState: Record<string, TranslationState>
translate: (
key: string,
text: string,
targetLangCode: string,
sourceLangCode?: string,
options?: Options,
) => Promise<void>
clearTranslation: (key: string) => void
acquireTranslation: (key: string) => () => void
} | null>(null)
Context.displayName = 'TranslationContext'
+243
View File
@@ -0,0 +1,243 @@
import {useCallback, useContext, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
import {useFocusEffect} from '@react-navigation/native'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
import {Context} from './context'
import {type Options, type TranslationState} from './types'
/**
* 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 =
Platform.OS === 'android'
? targetLangCodeOriginal.split('-')[0]
: targetLangCodeOriginal
const sourceLangCode =
Platform.OS === 'android'
? sourceLangCodeOriginal?.split('-')[0]
: sourceLangCodeOriginal
// Special cases for regional languages
if (Platform.OS !== 'android') {
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
targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal
break
}
}
const {onTranslateTask} =
// Needed in order to type check the dynamically imported module.
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
require('@bsky.app/expo-translate-text') as typeof import('@bsky.app/expo-translate-text')
const result = await onTranslateTask({
input,
targetLangCode,
sourceLangCode,
})
// Since `input` is always a string, the result should always be a string.
return {
translatedText:
typeof result.translatedTexts === 'string' ? result.translatedTexts : '',
targetLanguage: result.targetLanguage,
sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // 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 useTranslateOnDevice() {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslateOnDevice must be used within a TranslateOnDeviceProvider',
)
}
return context
}
/**
* Hook to register a component as using a translation key.
* Automatically handles ref counting with screen focus management
* via useFocusEffect and cleans up the translation when the component
* loses focus.
*/
export function useTranslationKey(key: string) {
const {acquireTranslation} = useTranslateOnDevice()
useFocusEffect(
useCallback(() => {
const cleanup = acquireTranslation(key)
return cleanup
}, [key, acquireTranslation]),
)
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const [translationState, setTranslationState] = useState<
Record<string, TranslationState>
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
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) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => {
delete prev[key]
return {...prev}
})
}, [])
const translate = useCallback(
async (
key: string,
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode?: string,
options?: Options,
) => {
const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLangCode,
)
if (options?.googleTranslate) {
ax.metric('translate:result', {
method: 'google-translate',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
await openLink(translateUrl)
return
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {status: 'loading'},
}))
try {
const result = await attemptTranslation(
text,
targetLangCode,
sourceLangCode,
)
ax.metric('translate:result', {
method: 'on-device',
os: Platform.OS,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {
status: 'success',
translatedText: result.translatedText,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
},
}))
} catch (e) {
logger.error('Failed to translate post on device', {safeMessage: e})
// On-device translation failed (language pack missing or user
// dismissed the download prompt). Fall back to Google Translate.
ax.metric('translate:result', {
method: 'fallback-alert',
os: Platform.OS,
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setTranslationState(prev => ({
...prev,
[key]: {status: 'idle'},
}))
await openLink(translateUrl)
}
},
[ax, openLink, primaryLanguage],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[acquireTranslation, clearTranslation, translate, translationState],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+66
View File
@@ -0,0 +1,66 @@
import {useCallback, useContext, useMemo} from 'react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getTranslatorLink} from '#/locale/helpers'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
import {Context} from './context'
import {type Options, type TranslationState} from './types'
const translationState: Record<string, TranslationState> = {}
const acquireTranslation = (_key: string) => {
return () => {}
}
const clearTranslation = (_key: string) => {}
export function useTranslationKey(_key: string) {}
/**
* Web always opens Google Translate.
*/
export function useTranslateOnDevice() {
const context = useContext(Context)
if (!context) {
throw new Error(
'useTranslateOnDevice must be used within a TranslateOnDeviceProvider',
)
}
return context
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
const openLink = useOpenLink()
const ax = useAnalytics()
const {primaryLanguage} = useLanguagePrefs()
const translate = useCallback(
async (
_key: string,
text: string,
targetLangCode: string = primaryLanguage,
sourceLangCode?: string,
_options?: Options,
) => {
ax.metric('translate:result', {
method: 'google-translate',
os: 'web',
sourceLanguage: sourceLangCode ?? null,
targetLanguage: targetLangCode,
})
const translateUrl = getTranslatorLink(
text,
targetLangCode,
sourceLangCode,
)
await openLink(translateUrl)
},
[ax, openLink, primaryLanguage],
)
const ctx = useMemo(
() => ({acquireTranslation, clearTranslation, translate, translationState}),
[translate],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
+18
View File
@@ -0,0 +1,18 @@
import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types'
export type TranslationState =
| {status: 'idle'}
| {status: 'loading'}
| {
status: 'success'
translatedText: string
sourceLanguage: TranslationTaskResult['sourceLanguage']
targetLanguage: TranslationTaskResult['targetLanguage']
}
export type Options = {
/**
* Whether to force the use of Google Translate. Default is false.
*/
googleTranslate?: boolean
}