Refine post translation UX
This commit is contained in:
@@ -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'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user