On-device translation

This commit is contained in:
Alex Benzer
2026-02-15 15:58:36 -08:00
parent 06fcea988e
commit ea6cde2f6a
11 changed files with 296 additions and 96 deletions
+1
View File
@@ -164,6 +164,7 @@
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"expo-translate-text": "^0.1.0",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-video-thumbnails": "^10.0.8",
+3
View File
@@ -667,6 +667,9 @@ export type Events = {
targetLanguage: string
textLength: number
}
'translate:result': {
method: 'on-device' | 'google-translate' | 'fallback-alert'
}
'verification:create': {}
'verification:revoke': {}
+77
View File
@@ -0,0 +1,77 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {codeToLanguageName} from '#/locale/helpers'
import {useTranslationState} from '#/state/translation'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function TranslatedPost({
postUri,
hideLoading,
}: {
postUri: string
hideLoading?: boolean
}) {
const state = useTranslationState(postUri)
if (state.status === 'loading' && !hideLoading) {
return <TranslationLoading />
}
if (state.status === 'success') {
return (
<TranslationResult
translatedText={state.translatedText}
sourceLanguage={state.sourceLanguage}
/>
)
}
return null
}
function TranslationLoading() {
const t = useTheme()
return (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}>
<Loader size="sm" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating...</Trans>
</Text>
</View>
)
}
function TranslationResult({
translatedText,
sourceLanguage,
}: {
translatedText: string
sourceLanguage: string
}) {
const t = useTheme()
const {i18n} = useLingui()
const langName = sourceLanguage
? codeToLanguageName(sourceLanguage, i18n.locale)
: undefined
return (
<View style={[a.py_xs, a.gap_xs]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{langName ? (
<Trans>Translated from {langName}</Trans>
) : (
<Trans>Translated</Trans>
)}
</Text>
<Text selectable style={[a.text_md]}>
{translatedText}
</Text>
</View>
)
}
@@ -258,7 +258,7 @@ let PostMenuItems = ({
}
const onPressTranslate = () => {
translate(record.text, langPrefs.primaryLanguage)
translate(record.text, langPrefs.primaryLanguage, {postUri: post.uri})
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
+95 -40
View File
@@ -1,54 +1,109 @@
import {useCallback} from 'react'
import * as IntentLauncher from 'expo-intent-launcher'
import {Alert} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getTranslatorLink} from '#/locale/helpers'
import {IS_ANDROID} from '#/env'
import {logger} from '#/logger'
import {setTranslationState} from '#/state/translation'
import {useAnalytics} from '#/analytics'
import {useOpenLink} from './useOpenLink'
/**
* Attempts on-device translation via expo-translate-text.
* Uses a lazy require to avoid crashing if the native module
* isn't linked into the current build.
*/
async function attemptTranslation(
text: string,
language: string,
postUri: string,
): Promise<void> {
const {onTranslateTask} =
require('expo-translate-text') as typeof import('expo-translate-text')
const result = await onTranslateTask({
input: text,
targetLangCode: language,
})
setTranslationState(postUri, {
status: 'success',
translatedText:
typeof result.translatedTexts === 'string' ? result.translatedTexts : '',
sourceLanguage: result.sourceLanguage ?? '',
})
}
/**
* 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 or the user declines to download it.
*
* Web uses useTranslate.web.ts which always opens Google Translate.
*/
export function useTranslate() {
const openLink = useOpenLink()
const {_} = useLingui()
const ax = useAnalytics()
return useCallback(
async (text: string, language: string) => {
const translateUrl = getTranslatorLink(text, language)
if (IS_ANDROID) {
try {
// use getApplicationIconAsync to determine if the translate app is installed
if (
!(await IntentLauncher.getApplicationIconAsync(
'com.google.android.apps.translate',
))
) {
throw new Error('Translate app not installed')
}
// TODO: this should only be called one at a time, use something like
// RQ's `scope` - otherwise can trigger the browser to open unexpectedly when the call throws -sfn
await IntentLauncher.startActivityAsync(
'android.intent.action.PROCESS_TEXT',
{
type: 'text/plain',
extra: {
'android.intent.extra.PROCESS_TEXT': text,
'android.intent.extra.PROCESS_TEXT_READONLY': true,
},
// note: to skip the intermediate app select, we need to specify a
// `className`. however, this isn't safe to hardcode, we'd need to query the
// package manager for the correct activity. this requires native code, so
// skip for now -sfn
// packageName: 'com.google.android.apps.translate',
// className: 'com.google.android.apps.translate.TranslateActivity',
},
)
} catch (err) {
if (__DEV__) console.error(err)
// most likely means they don't have the translate app
await openLink(translateUrl)
}
} else {
async (text: string, language: string, opts?: {postUri?: string}) => {
// No postUri means non-post context (e.g. DMs) — open Google Translate
if (!opts?.postUri) {
const translateUrl = getTranslatorLink(text, language)
await openLink(translateUrl)
return
}
const postUri = opts.postUri
setTranslationState(postUri, {status: 'loading'})
try {
await attemptTranslation(text, language, postUri)
ax.metric('translate:result', {method: 'on-device'})
} catch (err) {
logger.error('Failed to translate post', {safeMessage: err})
setTranslationState(postUri, {status: 'idle'})
// On-device translation failed (language pack missing or user
// dismissed the download prompt). Show options to retry or
// fall back to Google Translate.
ax.metric('translate:result', {method: 'fallback-alert'})
Alert.alert(
_(msg`Translation unavailable`),
_(msg`The required language pack is not installed on your device.`),
[
{
text: _(msg`Download language pack`),
onPress: async () => {
setTranslationState(postUri, {status: 'loading'})
try {
await attemptTranslation(text, language, postUri)
ax.metric('translate:result', {method: 'on-device'})
} catch (retryErr) {
logger.error('Failed to translate post', {
safeMessage: retryErr,
})
setTranslationState(postUri, {status: 'idle'})
}
},
},
{
text: _(msg`Use Google Translate`),
onPress: () => {
ax.metric('translate:result', {method: 'google-translate'})
openLink(getTranslatorLink(text, language))
},
},
{
text: _(msg`Cancel`),
style: 'cancel',
},
],
)
}
},
[openLink],
[_, ax, openLink],
)
}
+16
View File
@@ -0,0 +1,16 @@
import {useCallback} from 'react'
import {getTranslatorLink} from '#/locale/helpers'
import {useOpenLink} from './useOpenLink'
export function useTranslate() {
const openLink = useOpenLink()
return useCallback(
async (text: string, language: string, _opts?: {postUri?: string}) => {
const translateUrl = getTranslatorLink(text, language)
await openLink(translateUrl)
},
[openLink],
)
}
@@ -30,6 +30,7 @@ import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {clearTranslation, useTranslationState} from '#/state/translation'
import {type PostSource} from '#/state/unstable-post-source'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
@@ -45,11 +46,13 @@ import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {TranslatedPost} from '#/components/Post/TranslatedPost'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -412,6 +415,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
shouldProxyLinks={true}
/>
) : undefined}
<TranslatedPost postUri={post.uri} hideLoading />
{post.embed && (
<View style={[a.py_xs]}>
<Embed
@@ -558,10 +562,14 @@ function ExpandedPostDetails({
[post, langPrefs.primaryLanguage],
)
const translationState = useTranslationState(post.uri)
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
translate(post.record.text || '', langPrefs.primaryLanguage)
translate(post.record.text || '', langPrefs.primaryLanguage, {
postUri: post.uri,
})
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
@@ -581,6 +589,15 @@ function ExpandedPostDetails({
[ax, translate, langPrefs, post],
)
const onHideTranslation = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
clearTranslation(post.uri)
return false
},
[post.uri],
)
return (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
<BackdatedPostIndicator post={post} />
@@ -597,18 +614,33 @@ function ExpandedPostDetails({
&middot;
</Text>
<InlineLinkText
// overridden to open an intent on android, but keep
// as anchor tag for accessibility
to={getTranslatorLink(
post.record.text,
langPrefs.primaryLanguage,
)}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
{translationState.status === 'loading' ? (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Loader size="xs" />
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Translating...</Trans>
</Text>
</View>
) : translationState.status === 'success' ? (
<InlineLinkText
to="#"
label={_(msg`Hide translation`)}
style={[a.text_sm]}
onPress={onHideTranslation}>
<Trans>Hide translation</Trans>
</InlineLinkText>
) : (
<InlineLinkText
to={getTranslatorLink(
post.record.text,
langPrefs.primaryLanguage,
)}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
)}
</>
)}
</View>
+45
View File
@@ -0,0 +1,45 @@
/**
* Per-post translation state store. Uses the EventEmitter + Map pattern
* (same as post-shadow.ts) so only components subscribed to a specific
* post URI re-render when its translation state changes.
*/
import {useEffect, useState} from 'react'
import EventEmitter from 'eventemitter3'
export type TranslationState =
| {status: 'idle'}
| {status: 'loading'}
| {status: 'success'; translatedText: string; sourceLanguage: string}
const IDLE: TranslationState = {status: 'idle'}
const emitter = new EventEmitter()
const translations = new Map<string, TranslationState>()
export function setTranslationState(postUri: string, state: TranslationState) {
translations.set(postUri, state)
emitter.emit(postUri)
}
export function clearTranslation(postUri: string) {
translations.delete(postUri)
emitter.emit(postUri)
}
export function useTranslationState(postUri: string): TranslationState {
const [state, setState] = useState<TranslationState>(
() => translations.get(postUri) ?? IDLE,
)
useEffect(() => {
function onUpdate() {
setState(translations.get(postUri) ?? IDLE)
}
emitter.addListener(postUri, onUpdate)
return () => {
emitter.removeListener(postUri, onUpdate)
}
}, [postUri])
return state
}
+2
View File
@@ -33,6 +33,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/TranslatedPost'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover'
@@ -217,6 +218,7 @@ function PostInner({
)}
</View>
) : undefined}
<TranslatedPost postUri={post.uri} />
{post.embed ? (
<Embed
embed={post.embed}
+2
View File
@@ -43,6 +43,7 @@ import {Embed} from '#/components/Post/Embed'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/TranslatedPost'
import {PostControls} from '#/components/PostControls'
import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
import {RichText} from '#/components/RichText'
@@ -481,6 +482,7 @@ let PostContent = ({
)}
</View>
) : undefined}
<TranslatedPost postUri={post.uri} />
{postEmbed ? (
<View style={[a.pb_xs]}>
<Embed
+9 -42
View File
@@ -3403,7 +3403,7 @@
"@babel/parser" "^7.27.2"
"@babel/types" "^7.27.1"
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
@@ -3464,19 +3464,6 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
dependencies:
"@babel/code-frame" "^7.25.9"
"@babel/generator" "^7.25.9"
"@babel/parser" "^7.25.9"
"@babel/template" "^7.25.9"
"@babel/types" "^7.25.9"
debug "^4.3.1"
globals "^11.1.0"
"@babel/traverse@^7.26.10":
version "7.26.10"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.10.tgz#43cca33d76005dbaa93024fae536cc1946a4c380"
@@ -11777,6 +11764,11 @@ expo-task-manager@~14.0.9:
dependencies:
unimodules-app-loader "~6.0.8"
expo-translate-text@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/expo-translate-text/-/expo-translate-text-0.1.0.tgz#16904f1baac126d5a452b4196495d238dcbbb6af"
integrity sha512-zrRJ0Do6Gie12yZYzgD4McQNIvb54pfPsBRegkUSuADec9DrXdxJXvw/OieZ+L4oMQu0Fx/NJDnHGQoaa0CVgA==
expo-updates-interface@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749"
@@ -18691,16 +18683,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -18859,7 +18842,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -18873,13 +18856,6 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -20276,7 +20252,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -20294,15 +20270,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"